mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-12 21:09:24 +00:00
Compare commits
176 Commits
Author | SHA1 | Date | |
---|---|---|---|
f477346f1e | |||
8cc23f01fd | |||
d456a1d9d8 | |||
e200f28267 | |||
a4c6439981 | |||
6ee2e8c5da | |||
f4af29acb4 | |||
b26e85ebb2 | |||
8b8a295e16 | |||
894690a97f | |||
843ce2e3a7 | |||
9f33ddd883 | |||
a2dc4f1964 | |||
cca486f2b9 | |||
b9e27ef191 | |||
29e875bcde | |||
4226ba88ae | |||
672bc91e36 | |||
bc9cb9b36c | |||
edafc06c3f | |||
134f8a7989 | |||
771cbdbd77 | |||
a491baddb9 | |||
8d10a5e0ab | |||
cbe42d3a60 | |||
0c4d72e17a | |||
2db3290bed | |||
429df81ec9 | |||
bb24516474 | |||
8e339c3d4b | |||
25298518bc | |||
2e26ff2cde | |||
a8702d4f5e | |||
7f4cc5fb0f | |||
e7215be00e | |||
4f35cc00bc | |||
af47587116 | |||
3c4daeadb0 | |||
348ab19ac6 | |||
ada74a3e42 | |||
785ac9bdab | |||
d1e604e7a5 | |||
2749a88704 | |||
eb3db134f8 | |||
fa7f5d24b0 | |||
ba76015c71 | |||
1887a2790f | |||
5d0fc38107 | |||
65df55a635 | |||
a60e1f1fdc | |||
0a0f44607e | |||
007080ee48 | |||
a13e3e95b7 | |||
169938e1da | |||
e5fb875968 | |||
9bd14f1b4e | |||
fd2295119d | |||
eac2709e86 | |||
48f6d134bf | |||
6d7467e746 | |||
0d1629495b | |||
63839dc22b | |||
c627944bd1 | |||
f0be4b270b | |||
728065e771 | |||
849fac490d | |||
5752229312 | |||
6eb860579a | |||
d08d54b4c9 | |||
bb210f8d47 | |||
efca10e252 | |||
b8f851b009 | |||
1e29ae4171 | |||
0ae1e850f4 | |||
d380668c0f | |||
9a72c5eb72 | |||
407cfbe02b | |||
6400b2c2c2 | |||
0d3fd954a3 | |||
cce7ecbe73 | |||
d200c1e381 | |||
2f9e5bfee1 | |||
11702d3d7c | |||
43f47e2087 | |||
4b881903f0 | |||
761e37ade6 | |||
10ed2f7404 | |||
43768f1691 | |||
280d27343d | |||
138e1662c7 | |||
c80fe094a2 | |||
139221dd22 | |||
72c2981d66 | |||
6f003ffa53 | |||
7f6fd7dc47 | |||
ccf5118c9d | |||
022c19c020 | |||
2e5e217842 | |||
9c93c004ec | |||
472800eff6 | |||
b8d43a629b | |||
f6693e12cf | |||
38f491cf57 | |||
3464d5d860 | |||
81773dc800 | |||
da400155eb | |||
5079938a5b | |||
b8430adc50 | |||
eed434aeb2 | |||
372470b44b | |||
7071553c3b | |||
eea237b813 | |||
c37288c285 | |||
ccf5767a01 | |||
ffe6fc8c2a | |||
5f0b4d2050 | |||
f9d7dc7d3a | |||
8ebea00896 | |||
a548f4744e | |||
a383f3e717 | |||
60f18669c8 | |||
a36946529b | |||
eaac0c728f | |||
9ca95774a3 | |||
fa66851889 | |||
ab81f11199 | |||
bea7741d35 | |||
81e163812e | |||
4f233538e0 | |||
4ac09ac08b | |||
6d02970676 | |||
accf1befce | |||
6953eaa9d0 | |||
180bae59c8 | |||
9cafc163e8 | |||
8f8eef4107 | |||
7af745087e | |||
aab093200b | |||
1a9556f811 | |||
39b36aa509 | |||
3fc260a12c | |||
1696a2f579 | |||
baebc0b690 | |||
9f3a10ca71 | |||
38ceaf3106 | |||
87b619794d | |||
29c4a36479 | |||
ddeb12f08c | |||
a7b1406e06 | |||
871ef60cf6 | |||
7e9071a533 | |||
8c660e9856 | |||
6ca641ab7f | |||
8d6609566f | |||
17e4c63737 | |||
fad470b670 | |||
c5646b1e59 | |||
5da27bb266 | |||
be6a077c8e | |||
8d643fc6b4 | |||
47876e3971 | |||
c79c251ba7 | |||
483670e798 | |||
6c8a6008d5 | |||
e680124d7b | |||
b06a06f50c | |||
6416de237f | |||
ad44fd52a4 | |||
03c27b10e5 | |||
fc0ef29738 | |||
a0eb53a607 | |||
66e5ef64fd | |||
379527cd58 | |||
f63fb59cbf | |||
cde864fdc7 | |||
e9361697f7 |
83
.env.example
Normal file
83
.env.example
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
HTTPS=off
|
||||||
|
DEBUG=true
|
||||||
|
|
||||||
|
# This is not the real key used in prod
|
||||||
|
SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2
|
||||||
|
|
||||||
|
DATABASE_URL=sqlite:///db.sqlite3
|
||||||
|
# uncomment the next line if you want to use a postgres database
|
||||||
|
#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith
|
||||||
|
CACHE_URL=redis://127.0.0.1:6379/0
|
||||||
|
|
||||||
|
MEDIA_ROOT=data
|
||||||
|
STATIC_ROOT=static
|
||||||
|
|
||||||
|
DEFAULT_FROM_EMAIL=bibou@git.an
|
||||||
|
SITH_COM_EMAIL=bibou_com@git.an
|
||||||
|
|
||||||
|
HONEYPOT_VALUE=content
|
||||||
|
HONEYPOT_FIELD_NAME=body2
|
||||||
|
HONEYPOT_FIELD_NAME_FORUM=message2
|
||||||
|
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||||
|
EMAIL_HOST=localhost
|
||||||
|
EMAIL_PORT=25
|
||||||
|
|
||||||
|
SITH_URL=127.0.0.1:8000
|
||||||
|
SITH_NAME="AE UTBM"
|
||||||
|
|
||||||
|
SITH_MAIN_CLUB_ID=1
|
||||||
|
|
||||||
|
SITH_GROUP_ROOT_ID=1
|
||||||
|
SITH_GROUP_PUBLIC_ID=2
|
||||||
|
SITH_GROUP_SUBSCRIBERS_ID=3
|
||||||
|
SITH_GROUP_OLD_SUBSCRIBERS_ID=4
|
||||||
|
SITH_GROUP_ACCOUNTING_ADMIN_ID=5
|
||||||
|
SITH_GROUP_COM_ADMIN_ID=6
|
||||||
|
SITH_GROUP_COUNTER_ADMIN_ID=7
|
||||||
|
SITH_GROUP_SAS_ADMIN_ID=8
|
||||||
|
SITH_GROUP_FORUM_ADMIN_ID=9
|
||||||
|
SITH_GROUP_PEDAGOGY_ADMIN_ID=10
|
||||||
|
|
||||||
|
SITH_GROUP_BANNED_ALCOHOL_ID=11
|
||||||
|
SITH_GROUP_BANNED_COUNTER_ID=12
|
||||||
|
SITH_GROUP_BANNED_SUBSCRIPTION_ID=13
|
||||||
|
|
||||||
|
SITH_CLUB_REFOUND_ID=89
|
||||||
|
SITH_COUNTER_REFOUND_ID=38
|
||||||
|
SITH_PRODUCT_REFOUND_ID=5
|
||||||
|
|
||||||
|
# Counter
|
||||||
|
|
||||||
|
SITH_COUNTER_ACCOUNT_DUMP_ID=39
|
||||||
|
|
||||||
|
# Defines which product type is the refilling type, and thus increases the account amount
|
||||||
|
SITH_COUNTER_PRODUCTTYPE_REFILLING=3
|
||||||
|
|
||||||
|
SITH_ECOCUP_CONS=1152
|
||||||
|
SITH_ECOCUP_DECO=1151
|
||||||
|
|
||||||
|
# Defines which product is the one year subscription and which one is the six month subscription
|
||||||
|
SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER=1
|
||||||
|
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS=2
|
||||||
|
SITH_PRODUCTTYPE_SUBSCRIPTION=2
|
||||||
|
|
||||||
|
# Defines which clubs let its members the ability to see users subscription history
|
||||||
|
SITH_CAN_CREATE_SUBSCRIPTION_HISTORY=1
|
||||||
|
SITH_CAN_READ_SUBSCRIPTION_HISTORY=1
|
||||||
|
|
||||||
|
# SAS variables
|
||||||
|
SITH_SAS_ROOT_DIR_ID=4
|
||||||
|
|
||||||
|
# ET variables
|
||||||
|
SITH_EBOUTIC_CB_ENABLED=true
|
||||||
|
SITH_EBOUTIC_ET_URL="https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
|
||||||
|
SITH_EBOUTIC_PBX_SITE=1999888
|
||||||
|
SITH_EBOUTIC_PBX_RANG=32
|
||||||
|
SITH_EBOUTIC_PBX_IDENTIFIANT=2
|
||||||
|
SITH_EBOUTIC_HMAC_KEY=0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF
|
||||||
|
SITH_EBOUTIC_PUB_KEY_PATH=sith/et_keys/pubkey.pem
|
||||||
|
|
||||||
|
SITH_MAILING_FETCH_KEY=IloveMails
|
||||||
|
SENTRY_DSN=
|
||||||
|
SENTRY_ENV=production
|
14
.envrc
14
.envrc
@ -1,14 +1,6 @@
|
|||||||
if [[ ! -f pyproject.toml ]]; then
|
if [[ ! -d .venv ]]; then
|
||||||
log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
|
log_error 'No .venv folder found. Use `uv sync` to create one first.'
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local VENV=$(poetry env list --full-path | cut -d' ' -f1)
|
. .venv/bin/activate
|
||||||
if [[ -z $VENV || ! -d $VENV/bin ]]; then
|
|
||||||
log_error 'No poetry virtual environment found. Use `poetry install` to create one first.'
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
export VIRTUAL_ENV=$VENV
|
|
||||||
export POETRY_ACTIVE=1
|
|
||||||
PATH_add "$VENV/bin"
|
|
8
.github/actions/compile_messages/action.yml
vendored
8
.github/actions/compile_messages/action.yml
vendored
@ -1,8 +0,0 @@
|
|||||||
name: "Compile messages"
|
|
||||||
description: "Compile the gettext translation messages"
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Setup project
|
|
||||||
run: poetry run ./manage.py compilemessages
|
|
||||||
shell: bash
|
|
51
.github/actions/setup_project/action.yml
vendored
51
.github/actions/setup_project/action.yml
vendored
@ -9,43 +9,38 @@ runs:
|
|||||||
packages: gettext
|
packages: gettext
|
||||||
version: 1.0 # increment to reset cache
|
version: 1.0 # increment to reset cache
|
||||||
|
|
||||||
- name: Set up python
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.5.14"
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: "uv.lock"
|
||||||
|
|
||||||
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version-file: ".python-version"
|
||||||
|
|
||||||
- name: Load cached Poetry installation
|
- name: Restore cached virtualenv
|
||||||
id: cached-poetry
|
uses: actions/cache/restore@v4
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
with:
|
||||||
path: ~/.local
|
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||||
key: poetry-3 # increment to reset cache
|
path: .venv
|
||||||
|
|
||||||
- name: Install Poetry
|
|
||||||
if: steps.cached-poetry.outputs.cache-hit != 'true'
|
|
||||||
shell: bash
|
|
||||||
run: curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
|
|
||||||
- name: Check pyproject.toml syntax
|
|
||||||
shell: bash
|
|
||||||
run: poetry check
|
|
||||||
|
|
||||||
- name: Load cached dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pypoetry
|
|
||||||
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-poetry-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: poetry install --with docs,tests
|
run: uv sync
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install xapian
|
- name: Install Xapian
|
||||||
run: poetry run ./manage.py install_xapian
|
run: uv run ./manage.py install_xapian
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
- name: Save cached virtualenv
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||||
|
path: .venv
|
||||||
|
|
||||||
- name: Compile gettext messages
|
- name: Compile gettext messages
|
||||||
run: poetry run ./manage.py compilemessages
|
run: uv run ./manage.py compilemessages
|
||||||
shell: bash
|
shell: bash
|
||||||
|
10
.github/actions/setup_xapian/action.yml
vendored
10
.github/actions/setup_xapian/action.yml
vendored
@ -1,10 +0,0 @@
|
|||||||
name: "Setup xapian"
|
|
||||||
description: "Setup the xapian indexes"
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Setup xapian index
|
|
||||||
run: |
|
|
||||||
mkdir -p /dev/shm/search_indexes
|
|
||||||
ln -s /dev/shm/search_indexes sith/search_indexes
|
|
||||||
shell: bash
|
|
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@ -7,6 +7,10 @@ on:
|
|||||||
branches: [master, taiste]
|
branches: [master, taiste]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
SECRET_KEY: notTheRealOne
|
||||||
|
DATABASE_URL: sqlite:///db.sqlite3
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
name: Launch pre-commits checks (ruff)
|
name: Launch pre-commits checks (ruff)
|
||||||
@ -14,6 +18,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version-file: ".python-version"
|
||||||
- uses: pre-commit/action@v3.0.1
|
- uses: pre-commit/action@v3.0.1
|
||||||
with:
|
with:
|
||||||
extra_args: --all-files
|
extra_args: --all-files
|
||||||
@ -29,14 +35,15 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
- uses: ./.github/actions/setup_xapian
|
env:
|
||||||
- uses: ./.github/actions/compile_messages
|
# To avoid race conditions on environment cache
|
||||||
|
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: poetry run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
|
run: uv run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
|
||||||
- name: Generate coverage report
|
- name: Generate coverage report
|
||||||
run: |
|
run: |
|
||||||
poetry run coverage report
|
uv run coverage report
|
||||||
poetry run coverage html
|
uv run coverage html
|
||||||
- name: Archive code coverage results
|
- name: Archive code coverage results
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
10
.github/workflows/deploy.yml
vendored
10
.github/workflows/deploy.yml
vendored
@ -37,12 +37,12 @@ jobs:
|
|||||||
|
|
||||||
git fetch
|
git fetch
|
||||||
git reset --hard origin/master
|
git reset --hard origin/master
|
||||||
poetry install --with prod --without docs,tests
|
uv sync --group prod
|
||||||
npm install
|
npm install
|
||||||
poetry run ./manage.py install_xapian
|
uv run ./manage.py install_xapian
|
||||||
poetry run ./manage.py migrate
|
uv run ./manage.py migrate
|
||||||
poetry run ./manage.py collectstatic --clear --noinput
|
uv run ./manage.py collectstatic --clear --noinput
|
||||||
poetry run ./manage.py compilemessages
|
uv run ./manage.py compilemessages
|
||||||
|
|
||||||
sudo systemctl restart uwsgi
|
sudo systemctl restart uwsgi
|
||||||
|
|
||||||
|
2
.github/workflows/deploy_docs.yml
vendored
2
.github/workflows/deploy_docs.yml
vendored
@ -18,4 +18,4 @@ jobs:
|
|||||||
path: .cache
|
path: .cache
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
mkdocs-material-
|
mkdocs-material-
|
||||||
- run: poetry run mkdocs gh-deploy --force
|
- run: uv run mkdocs gh-deploy --force
|
10
.github/workflows/taiste.yml
vendored
10
.github/workflows/taiste.yml
vendored
@ -36,11 +36,11 @@ jobs:
|
|||||||
|
|
||||||
git fetch
|
git fetch
|
||||||
git reset --hard origin/taiste
|
git reset --hard origin/taiste
|
||||||
poetry install --with prod --without docs,tests
|
uv sync --group prod
|
||||||
npm install
|
npm install
|
||||||
poetry run ./manage.py install_xapian
|
uv run ./manage.py install_xapian
|
||||||
poetry run ./manage.py migrate
|
uv run ./manage.py migrate
|
||||||
poetry run ./manage.py collectstatic --clear --noinput
|
uv run ./manage.py collectstatic --clear --noinput
|
||||||
poetry run ./manage.py compilemessages
|
uv run ./manage.py compilemessages
|
||||||
|
|
||||||
sudo systemctl restart uwsgi
|
sudo systemctl restart uwsgi
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,7 +8,7 @@ pyrightconfig.json
|
|||||||
dist/
|
dist/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
env/
|
.venv/
|
||||||
doc/html
|
doc/html
|
||||||
data/
|
data/
|
||||||
galaxy/test_galaxy_state.json
|
galaxy/test_galaxy_state.json
|
||||||
@ -21,3 +21,4 @@ node_modules/
|
|||||||
|
|
||||||
# compiled documentation
|
# compiled documentation
|
||||||
site/
|
site/
|
||||||
|
.env
|
||||||
|
1
.python-version
Normal file
1
.python-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
3.12
|
@ -216,7 +216,7 @@ class TestOperation(TestCase):
|
|||||||
self.journal.operations.filter(target_label="Le fantome du jour").exists()
|
self.journal.operations.filter(target_label="Le fantome du jour").exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
def test__operation_simple_accounting(self):
|
def test_operation_simple_accounting(self):
|
||||||
sat = SimplifiedAccountingType.objects.all().first()
|
sat = SimplifiedAccountingType.objects.all().first()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("accounting:op_new", args=[self.journal.id]),
|
reverse("accounting:op_new", args=[self.journal.id]),
|
||||||
@ -237,15 +237,14 @@ class TestOperation(TestCase):
|
|||||||
"done": False,
|
"done": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertFalse(response.status_code == 403)
|
assert response.status_code != 403
|
||||||
self.assertTrue(self.journal.operations.filter(amount=23).exists())
|
assert self.journal.operations.filter(amount=23).exists()
|
||||||
response_get = self.client.get(
|
response_get = self.client.get(
|
||||||
reverse("accounting:journal_details", args=[self.journal.id])
|
reverse("accounting:journal_details", args=[self.journal.id])
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
assert "<td>Le fantome de l'aurore</td>" in str(response_get.content)
|
||||||
"<td>Le fantome de l'aurore</td>" in str(response_get.content)
|
|
||||||
)
|
assert (
|
||||||
self.assertTrue(
|
|
||||||
self.journal.operations.filter(amount=23)
|
self.journal.operations.filter(amount=23)
|
||||||
.values("accounting_type")
|
.values("accounting_type")
|
||||||
.first()["accounting_type"]
|
.first()["accounting_type"]
|
||||||
|
@ -215,17 +215,14 @@ class JournalTabsMixin(TabedViewMixin):
|
|||||||
return _("Journal")
|
return _("Journal")
|
||||||
|
|
||||||
def get_list_of_tabs(self):
|
def get_list_of_tabs(self):
|
||||||
tab_list = []
|
return [
|
||||||
tab_list.append(
|
|
||||||
{
|
{
|
||||||
"url": reverse(
|
"url": reverse(
|
||||||
"accounting:journal_details", kwargs={"j_id": self.object.id}
|
"accounting:journal_details", kwargs={"j_id": self.object.id}
|
||||||
),
|
),
|
||||||
"slug": "journal",
|
"slug": "journal",
|
||||||
"name": _("Journal"),
|
"name": _("Journal"),
|
||||||
}
|
},
|
||||||
)
|
|
||||||
tab_list.append(
|
|
||||||
{
|
{
|
||||||
"url": reverse(
|
"url": reverse(
|
||||||
"accounting:journal_nature_statement",
|
"accounting:journal_nature_statement",
|
||||||
@ -233,9 +230,7 @@ class JournalTabsMixin(TabedViewMixin):
|
|||||||
),
|
),
|
||||||
"slug": "nature_statement",
|
"slug": "nature_statement",
|
||||||
"name": _("Statement by nature"),
|
"name": _("Statement by nature"),
|
||||||
}
|
},
|
||||||
)
|
|
||||||
tab_list.append(
|
|
||||||
{
|
{
|
||||||
"url": reverse(
|
"url": reverse(
|
||||||
"accounting:journal_person_statement",
|
"accounting:journal_person_statement",
|
||||||
@ -243,9 +238,7 @@ class JournalTabsMixin(TabedViewMixin):
|
|||||||
),
|
),
|
||||||
"slug": "person_statement",
|
"slug": "person_statement",
|
||||||
"name": _("Statement by person"),
|
"name": _("Statement by person"),
|
||||||
}
|
},
|
||||||
)
|
|
||||||
tab_list.append(
|
|
||||||
{
|
{
|
||||||
"url": reverse(
|
"url": reverse(
|
||||||
"accounting:journal_accounting_statement",
|
"accounting:journal_accounting_statement",
|
||||||
@ -253,9 +246,8 @@ class JournalTabsMixin(TabedViewMixin):
|
|||||||
),
|
),
|
||||||
"slug": "accounting_statement",
|
"slug": "accounting_statement",
|
||||||
"name": _("Accounting statement"),
|
"name": _("Accounting statement"),
|
||||||
}
|
},
|
||||||
)
|
]
|
||||||
return tab_list
|
|
||||||
|
|
||||||
|
|
||||||
class JournalCreateView(CanCreateMixin, CreateView):
|
class JournalCreateView(CanCreateMixin, CreateView):
|
||||||
|
@ -20,6 +20,14 @@ from club.models import Club, Membership
|
|||||||
@admin.register(Club)
|
@admin.register(Club)
|
||||||
class ClubAdmin(admin.ModelAdmin):
|
class ClubAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "unix_name", "parent", "is_active")
|
list_display = ("name", "unix_name", "parent", "is_active")
|
||||||
|
search_fields = ("name", "unix_name")
|
||||||
|
autocomplete_fields = (
|
||||||
|
"parent",
|
||||||
|
"board_group",
|
||||||
|
"members_group",
|
||||||
|
"home",
|
||||||
|
"page",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Membership)
|
@admin.register(Membership)
|
||||||
|
@ -3,19 +3,6 @@ from __future__ import unicode_literals
|
|||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
from club.models import Club
|
|
||||||
from core.operations import PsqlRunOnly
|
|
||||||
|
|
||||||
|
|
||||||
def generate_club_pages(apps, schema_editor):
|
|
||||||
def recursive_generate_club_page(club):
|
|
||||||
club.make_page()
|
|
||||||
for child in Club.objects.filter(parent=club).all():
|
|
||||||
recursive_generate_club_page(child)
|
|
||||||
|
|
||||||
for club in Club.objects.filter(parent=None).all():
|
|
||||||
recursive_generate_club_page(club)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
|
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
|
||||||
@ -48,11 +35,4 @@ class Migration(migrations.Migration):
|
|||||||
null=True,
|
null=True,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PsqlRunOnly(
|
|
||||||
"SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop
|
|
||||||
),
|
|
||||||
migrations.RunPython(generate_club_pages),
|
|
||||||
PsqlRunOnly(
|
|
||||||
migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE"
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
106
club/migrations/0012_club_board_group_club_members_group.py
Normal file
106
club/migrations/0012_club_board_group_club_members_group.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-11-20 17:08
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.db.models.functions.datetime
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.timezone import localdate
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||||
|
"""Attach the existing meta groups to the clubs.
|
||||||
|
|
||||||
|
Until now, the meta groups were not attached to the clubs,
|
||||||
|
nor to the users.
|
||||||
|
This creates actual foreign relationships between the clubs
|
||||||
|
and theirs groups and the users and theirs groups.
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
When the meta groups associated with the clubs aren't found,
|
||||||
|
they are created.
|
||||||
|
Thus the migration shouldn't fail, and all the clubs will
|
||||||
|
have their groups.
|
||||||
|
However, there will probably be some groups that have
|
||||||
|
not been found but exist nonetheless,
|
||||||
|
so there will be duplicates and dangling groups.
|
||||||
|
There must be a manual cleanup after this migration.
|
||||||
|
"""
|
||||||
|
Group = apps.get_model("core", "Group")
|
||||||
|
Club = apps.get_model("club", "Club")
|
||||||
|
|
||||||
|
meta_groups = Group.objects.filter(is_meta=True)
|
||||||
|
clubs = list(Club.objects.all())
|
||||||
|
for club in clubs:
|
||||||
|
club.board_group = meta_groups.get_or_create(
|
||||||
|
name=club.unix_name + settings.SITH_BOARD_SUFFIX,
|
||||||
|
defaults={"is_meta": True},
|
||||||
|
)[0]
|
||||||
|
club.members_group = meta_groups.get_or_create(
|
||||||
|
name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
|
||||||
|
defaults={"is_meta": True},
|
||||||
|
)[0]
|
||||||
|
club.save()
|
||||||
|
club.refresh_from_db()
|
||||||
|
memberships = club.members.filter(
|
||||||
|
Q(end_date=None) | Q(end_date__gt=localdate())
|
||||||
|
).select_related("user")
|
||||||
|
club.members_group.users.set([m.user for m in memberships])
|
||||||
|
club.board_group.users.set(
|
||||||
|
[
|
||||||
|
m.user
|
||||||
|
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# steps of the migration :
|
||||||
|
# - Create a nullable field for the board group and the member group
|
||||||
|
# - Edit those new fields to make them point to currently existing meta groups
|
||||||
|
# - When this data migration is done, make the fields non-nullable
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0040_alter_user_options_user_user_permissions_and_more"),
|
||||||
|
("club", "0011_auto_20180426_2013"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="club",
|
||||||
|
name="edit_groups",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="club",
|
||||||
|
name="owner_group",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="club",
|
||||||
|
name="view_groups",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="club",
|
||||||
|
name="board_group",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club_board",
|
||||||
|
to="core.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="club",
|
||||||
|
name="members_group",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club",
|
||||||
|
to="core.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_meta_groups, reverse_code=migrations.RunPython.noop, elidable=True
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2025-01-04 16:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("club", "0012_club_board_group_club_members_group")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="club",
|
||||||
|
name="board_group",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club_board",
|
||||||
|
to="core.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="club",
|
||||||
|
name="members_group",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="club",
|
||||||
|
to="core.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="membership",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
check=models.Q(("end_date__gte", models.F("start_date"))),
|
||||||
|
name="end_after_start",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
328
club/models.py
328
club/models.py
@ -23,7 +23,7 @@
|
|||||||
#
|
#
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Self
|
from typing import Iterable, Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import validators
|
from django.core import validators
|
||||||
@ -31,14 +31,14 @@ from django.core.cache import cache
|
|||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.core.validators import RegexValidator, validate_email
|
from django.core.validators import RegexValidator, validate_email
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Exists, F, OuterRef, Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
|
from core.models import Group, Notification, Page, SithFile, User
|
||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
|
||||||
@ -79,19 +79,6 @@ class Club(models.Model):
|
|||||||
_("short description"), max_length=1000, default="", blank=True, null=True
|
_("short description"), max_length=1000, default="", blank=True, null=True
|
||||||
)
|
)
|
||||||
address = models.CharField(_("address"), max_length=254)
|
address = models.CharField(_("address"), max_length=254)
|
||||||
|
|
||||||
owner_group = models.ForeignKey(
|
|
||||||
Group,
|
|
||||||
related_name="owned_club",
|
|
||||||
default=get_default_owner_group,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
edit_groups = models.ManyToManyField(
|
|
||||||
Group, related_name="editable_club", blank=True
|
|
||||||
)
|
|
||||||
view_groups = models.ManyToManyField(
|
|
||||||
Group, related_name="viewable_club", blank=True
|
|
||||||
)
|
|
||||||
home = models.OneToOneField(
|
home = models.OneToOneField(
|
||||||
SithFile,
|
SithFile,
|
||||||
related_name="home_of_club",
|
related_name="home_of_club",
|
||||||
@ -103,6 +90,12 @@ class Club(models.Model):
|
|||||||
page = models.OneToOneField(
|
page = models.OneToOneField(
|
||||||
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
|
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
|
members_group = models.OneToOneField(
|
||||||
|
Group, related_name="club", on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
board_group = models.OneToOneField(
|
||||||
|
Group, related_name="club_board", on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name", "unix_name"]
|
ordering = ["name", "unix_name"]
|
||||||
@ -112,23 +105,27 @@ class Club(models.Model):
|
|||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
old = Club.objects.filter(id=self.id).first()
|
creation = self._state.adding
|
||||||
creation = old is None
|
if not creation:
|
||||||
if not creation and old.unix_name != self.unix_name:
|
db_club = Club.objects.get(id=self.id)
|
||||||
self._change_unixname(self.unix_name)
|
if self.unix_name != db_club.unix_name:
|
||||||
|
self.home.name = self.unix_name
|
||||||
|
self.home.save()
|
||||||
|
if self.name != db_club.name:
|
||||||
|
self.board_group.name = f"{self.name} - Bureau"
|
||||||
|
self.board_group.save()
|
||||||
|
self.members_group.name = f"{self.name} - Membres"
|
||||||
|
self.members_group.save()
|
||||||
|
if creation:
|
||||||
|
self.board_group = Group.objects.create(
|
||||||
|
name=f"{self.name} - Bureau", is_manually_manageable=False
|
||||||
|
)
|
||||||
|
self.members_group = Group.objects.create(
|
||||||
|
name=f"{self.name} - Membres", is_manually_manageable=False
|
||||||
|
)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if creation:
|
if creation:
|
||||||
board = MetaGroup(name=self.unix_name + settings.SITH_BOARD_SUFFIX)
|
|
||||||
board.save()
|
|
||||||
member = MetaGroup(name=self.unix_name + settings.SITH_MEMBER_SUFFIX)
|
|
||||||
member.save()
|
|
||||||
subscribers = Group.objects.filter(
|
|
||||||
name=settings.SITH_MAIN_MEMBERS_GROUP
|
|
||||||
).first()
|
|
||||||
self.make_home()
|
self.make_home()
|
||||||
self.home.edit_groups.set([board])
|
|
||||||
self.home.view_groups.set([member, subscribers])
|
|
||||||
self.home.save()
|
|
||||||
self.make_page()
|
self.make_page()
|
||||||
cache.set(f"sith_club_{self.unix_name}", self)
|
cache.set(f"sith_club_{self.unix_name}", self)
|
||||||
|
|
||||||
@ -136,7 +133,8 @@ class Club(models.Model):
|
|||||||
return reverse("club:club_view", kwargs={"club_id": self.id})
|
return reverse("club:club_view", kwargs={"club_id": self.id})
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def president(self):
|
def president(self) -> Membership | None:
|
||||||
|
"""Fetch the membership of the current president of this club."""
|
||||||
return self.members.filter(
|
return self.members.filter(
|
||||||
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
||||||
).first()
|
).first()
|
||||||
@ -154,36 +152,18 @@ class Club(models.Model):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
self.check_loop()
|
self.check_loop()
|
||||||
|
|
||||||
def _change_unixname(self, old_name, new_name):
|
def make_home(self) -> None:
|
||||||
c = Club.objects.filter(unix_name=new_name).first()
|
if self.home:
|
||||||
if c is None:
|
return
|
||||||
# Update all the groups names
|
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
|
||||||
Group.objects.filter(name=old_name).update(name=new_name)
|
root = User.objects.filter(username="root").first()
|
||||||
Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
|
if home_root and root:
|
||||||
name=new_name + settings.SITH_BOARD_SUFFIX
|
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
|
||||||
)
|
home.save()
|
||||||
Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
|
self.home = home
|
||||||
name=new_name + settings.SITH_MEMBER_SUFFIX
|
self.save()
|
||||||
)
|
|
||||||
|
|
||||||
if self.home:
|
def make_page(self) -> None:
|
||||||
self.home.name = new_name
|
|
||||||
self.home.save()
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValidationError(_("A club with that unix_name already exists"))
|
|
||||||
|
|
||||||
def make_home(self):
|
|
||||||
if not self.home:
|
|
||||||
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
|
|
||||||
root = User.objects.filter(username="root").first()
|
|
||||||
if home_root and root:
|
|
||||||
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
|
|
||||||
home.save()
|
|
||||||
self.home = home
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def make_page(self):
|
|
||||||
root = User.objects.filter(username="root").first()
|
root = User.objects.filter(username="root").first()
|
||||||
if not self.page:
|
if not self.page:
|
||||||
club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
|
club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
|
||||||
@ -213,35 +193,34 @@ class Club(models.Model):
|
|||||||
self.page.parent = self.parent.page
|
self.page.parent = self.parent.page
|
||||||
self.page.save(force_lock=True)
|
self.page.save(force_lock=True)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||||
# Invalidate the cache of this club and of its memberships
|
# Invalidate the cache of this club and of its memberships
|
||||||
for membership in self.members.ongoing().select_related("user"):
|
for membership in self.members.ongoing().select_related("user"):
|
||||||
cache.delete(f"membership_{self.id}_{membership.user.id}")
|
cache.delete(f"membership_{self.id}_{membership.user.id}")
|
||||||
cache.delete(f"sith_club_{self.unix_name}")
|
cache.delete(f"sith_club_{self.unix_name}")
|
||||||
super().delete(*args, **kwargs)
|
self.board_group.delete()
|
||||||
|
self.members_group.delete()
|
||||||
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
def get_display_name(self):
|
def get_display_name(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
def is_owned_by(self, user: User) -> bool:
|
||||||
"""Method to see if that object can be super edited by the given user."""
|
"""Method to see if that object can be super edited by the given user."""
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
return user.is_board_member
|
return user.is_root or user.is_board_member
|
||||||
|
|
||||||
def get_full_logo_url(self):
|
def get_full_logo_url(self) -> str:
|
||||||
return "https://%s%s" % (settings.SITH_URL, self.logo.url)
|
return f"https://{settings.SITH_URL}{self.logo.url}"
|
||||||
|
|
||||||
def can_be_edited_by(self, user):
|
def can_be_edited_by(self, user: User) -> bool:
|
||||||
"""Method to see if that object can be edited by the given user."""
|
"""Method to see if that object can be edited by the given user."""
|
||||||
return self.has_rights_in_club(user)
|
return self.has_rights_in_club(user)
|
||||||
|
|
||||||
def can_be_viewed_by(self, user):
|
def can_be_viewed_by(self, user: User) -> bool:
|
||||||
"""Method to see if that object can be seen by the given user."""
|
"""Method to see if that object can be seen by the given user."""
|
||||||
sub = User.objects.filter(pk=user.pk).first()
|
return user.was_subscribed
|
||||||
if sub is None:
|
|
||||||
return False
|
|
||||||
return sub.was_subscribed
|
|
||||||
|
|
||||||
def get_membership_for(self, user: User) -> Membership | None:
|
def get_membership_for(self, user: User) -> Membership | None:
|
||||||
"""Return the current membership the given user.
|
"""Return the current membership the given user.
|
||||||
@ -262,9 +241,8 @@ class Club(models.Model):
|
|||||||
cache.set(f"membership_{self.id}_{user.id}", membership)
|
cache.set(f"membership_{self.id}_{user.id}", membership)
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
def has_rights_in_club(self, user):
|
def has_rights_in_club(self, user: User) -> bool:
|
||||||
m = self.get_membership_for(user)
|
return user.is_in_group(pk=self.board_group_id)
|
||||||
return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
|
|
||||||
|
|
||||||
|
|
||||||
class MembershipQuerySet(models.QuerySet):
|
class MembershipQuerySet(models.QuerySet):
|
||||||
@ -283,42 +261,65 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
"""
|
"""
|
||||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
|
||||||
def update(self, **kwargs):
|
def update(self, **kwargs) -> int:
|
||||||
"""Refresh the cache for the elements of the queryset.
|
"""Refresh the cache and edit group ownership.
|
||||||
|
|
||||||
Besides that, does the same job as a regular update method.
|
Update the cache, when necessary, remove
|
||||||
|
users from club groups they are no more in
|
||||||
|
and add them in the club groups they should be in.
|
||||||
|
|
||||||
Be aware that this adds a db query to retrieve the updated objects
|
Be aware that this adds three db queries :
|
||||||
|
one to retrieve the updated memberships,
|
||||||
|
one to perform group removal and one to perform
|
||||||
|
group attribution.
|
||||||
"""
|
"""
|
||||||
nb_rows = super().update(**kwargs)
|
nb_rows = super().update(**kwargs)
|
||||||
if nb_rows > 0:
|
if nb_rows == 0:
|
||||||
# if at least a row was affected, refresh the cache
|
# if no row was affected, no need to refresh the cache
|
||||||
for membership in self.all():
|
return 0
|
||||||
if membership.end_date is not None:
|
|
||||||
cache.set(
|
|
||||||
f"membership_{membership.club_id}_{membership.user_id}",
|
|
||||||
"not_member",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cache.set(
|
|
||||||
f"membership_{membership.club_id}_{membership.user_id}",
|
|
||||||
membership,
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete(self):
|
cache_memberships = {}
|
||||||
|
memberships = set(self.select_related("club"))
|
||||||
|
# delete all User-Group relations and recreate the necessary ones
|
||||||
|
# It's more concise to write and more reliable
|
||||||
|
Membership._remove_club_groups(memberships)
|
||||||
|
Membership._add_club_groups(memberships)
|
||||||
|
for member in memberships:
|
||||||
|
cache_key = f"membership_{member.club_id}_{member.user_id}"
|
||||||
|
if member.end_date is None:
|
||||||
|
cache_memberships[cache_key] = member
|
||||||
|
else:
|
||||||
|
cache_memberships[cache_key] = "not_member"
|
||||||
|
cache.set_many(cache_memberships)
|
||||||
|
return nb_rows
|
||||||
|
|
||||||
|
def delete(self) -> tuple[int, dict[str, int]]:
|
||||||
"""Work just like the default Django's delete() method,
|
"""Work just like the default Django's delete() method,
|
||||||
but add a cache invalidation for the elements of the queryset
|
but add a cache invalidation for the elements of the queryset
|
||||||
before the deletion.
|
before the deletion,
|
||||||
|
and a removal of the user from the club groups.
|
||||||
|
|
||||||
Be aware that this adds a db query to retrieve the deleted element.
|
Be aware that this adds some db queries :
|
||||||
As this first query take place before the deletion operation,
|
|
||||||
it will be performed even if the deletion fails.
|
- 1 to retrieve the deleted elements in order to perform
|
||||||
|
post-delete operations.
|
||||||
|
As we can't know if a delete will affect rows or not,
|
||||||
|
this query will always happen
|
||||||
|
- 1 query to remove the users from the club groups.
|
||||||
|
If the delete operation affected no row,
|
||||||
|
this query won't happen.
|
||||||
"""
|
"""
|
||||||
ids = list(self.values_list("club_id", "user_id"))
|
memberships = set(self.all())
|
||||||
nb_rows, _ = super().delete()
|
nb_rows, rows_counts = super().delete()
|
||||||
if nb_rows > 0:
|
if nb_rows > 0:
|
||||||
for club_id, user_id in ids:
|
Membership._remove_club_groups(memberships)
|
||||||
cache.set(f"membership_{club_id}_{user_id}", "not_member")
|
cache.set_many(
|
||||||
|
{
|
||||||
|
f"membership_{m.club_id}_{m.user_id}": "not_member"
|
||||||
|
for m in memberships
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return nb_rows, rows_counts
|
||||||
|
|
||||||
|
|
||||||
class Membership(models.Model):
|
class Membership(models.Model):
|
||||||
@ -361,6 +362,13 @@ class Membership(models.Model):
|
|||||||
|
|
||||||
objects = MembershipQuerySet.as_manager()
|
objects = MembershipQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.CheckConstraint(
|
||||||
|
check=Q(end_date__gte=F("start_date")), name="end_after_start"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
return (
|
||||||
f"{self.club.name} - {self.user.username} "
|
f"{self.club.name} - {self.user.username} "
|
||||||
@ -370,7 +378,14 @@ class Membership(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
# a save may either be an update or a creation
|
||||||
|
# and may result in either an ongoing or an ended membership.
|
||||||
|
# It could also be a retrogradation from the board to being a simple member.
|
||||||
|
# To avoid problems, the user is removed from the club groups beforehand ;
|
||||||
|
# he will be added back if necessary
|
||||||
|
self._remove_club_groups([self])
|
||||||
if self.end_date is None:
|
if self.end_date is None:
|
||||||
|
self._add_club_groups([self])
|
||||||
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
||||||
else:
|
else:
|
||||||
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
|
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
|
||||||
@ -378,11 +393,11 @@ class Membership(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
def is_owned_by(self, user: User) -> bool:
|
||||||
"""Method to see if that object can be super edited by the given user."""
|
"""Method to see if that object can be super edited by the given user."""
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
return user.is_board_member
|
return user.is_root or user.is_board_member
|
||||||
|
|
||||||
def can_be_edited_by(self, user: User) -> bool:
|
def can_be_edited_by(self, user: User) -> bool:
|
||||||
"""Check if that object can be edited by the given user."""
|
"""Check if that object can be edited by the given user."""
|
||||||
@ -392,9 +407,91 @@ class Membership(models.Model):
|
|||||||
return membership is not None and membership.role >= self.role
|
return membership is not None and membership.role >= self.role
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
|
self._remove_club_groups([self])
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
cache.delete(f"membership_{self.club_id}_{self.user_id}")
|
cache.delete(f"membership_{self.club_id}_{self.user_id}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_club_groups(
|
||||||
|
memberships: Iterable[Membership],
|
||||||
|
) -> tuple[int, dict[str, int]]:
|
||||||
|
"""Remove users of those memberships from the club groups.
|
||||||
|
|
||||||
|
For example, if a user is in the Troll club board,
|
||||||
|
he is in the board group and the members group of the Troll.
|
||||||
|
After calling this function, he will be in neither.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The result of the deletion queryset.
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
If this function isn't used in combination
|
||||||
|
with an actual deletion of the memberships,
|
||||||
|
it will result in an inconsistent state,
|
||||||
|
where users will be in the clubs, without
|
||||||
|
having the associated rights.
|
||||||
|
"""
|
||||||
|
clubs = {m.club_id for m in memberships}
|
||||||
|
users = {m.user_id for m in memberships}
|
||||||
|
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
|
||||||
|
return User.groups.through.objects.filter(
|
||||||
|
Q(group__in=groups) & Q(user__in=users)
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_club_groups(
|
||||||
|
memberships: Iterable[Membership],
|
||||||
|
) -> list[User.groups.through]:
|
||||||
|
"""Add users of those memberships to the club groups.
|
||||||
|
|
||||||
|
For example, if a user just joined the Troll club board,
|
||||||
|
he will be added in both the members group and the board group
|
||||||
|
of the club.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created User-Group relations.
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
If this function isn't used in combination
|
||||||
|
with an actual update/creation of the memberships,
|
||||||
|
it will result in an inconsistent state,
|
||||||
|
where users will have the rights associated to the
|
||||||
|
club, without actually being part of it.
|
||||||
|
"""
|
||||||
|
# only active membership (i.e. `end_date=None`)
|
||||||
|
# grant the attribution of club groups.
|
||||||
|
memberships = [m for m in memberships if m.end_date is None]
|
||||||
|
if not memberships:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
|
||||||
|
# if more than one membership hasn't its `club` attribute set
|
||||||
|
# it's less expensive to reload the whole query with
|
||||||
|
# a select_related than perform a distinct query
|
||||||
|
# to fetch each club.
|
||||||
|
ids = {m.id for m in memberships}
|
||||||
|
memberships = list(
|
||||||
|
Membership.objects.filter(id__in=ids).select_related("club")
|
||||||
|
)
|
||||||
|
club_groups = []
|
||||||
|
for membership in memberships:
|
||||||
|
club_groups.append(
|
||||||
|
User.groups.through(
|
||||||
|
user_id=membership.user_id,
|
||||||
|
group_id=membership.club.members_group_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
||||||
|
club_groups.append(
|
||||||
|
User.groups.through(
|
||||||
|
user_id=membership.user_id,
|
||||||
|
group_id=membership.club.board_group_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return User.groups.through.objects.bulk_create(
|
||||||
|
club_groups, ignore_conflicts=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Mailing(models.Model):
|
class Mailing(models.Model):
|
||||||
"""A Mailing list for a club.
|
"""A Mailing list for a club.
|
||||||
@ -438,19 +535,18 @@ class Mailing(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.is_moderated:
|
if not self.is_moderated:
|
||||||
for user in (
|
unread_notif_subquery = Notification.objects.filter(
|
||||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
|
||||||
.first()
|
)
|
||||||
.users.all()
|
for user in User.objects.filter(
|
||||||
|
~Exists(unread_notif_subquery),
|
||||||
|
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||||
):
|
):
|
||||||
if not user.notifications.filter(
|
Notification(
|
||||||
type="MAILING_MODERATION", viewed=False
|
user=user,
|
||||||
).exists():
|
url=reverse("com:mailing_admin"),
|
||||||
Notification(
|
type="MAILING_MODERATION",
|
||||||
user=user,
|
).save(*args, **kwargs)
|
||||||
url=reverse("com:mailing_admin"),
|
|
||||||
type="MAILING_MODERATION",
|
|
||||||
).save(*args, **kwargs)
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
117
club/tests.py
117
club/tests.py
@ -21,6 +21,7 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import localdate, localtime, now
|
from django.utils.timezone import localdate, localtime, now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
from club.forms import MailingForm
|
from club.forms import MailingForm
|
||||||
from club.models import Club, Mailing, Membership
|
from club.models import Club, Mailing, Membership
|
||||||
@ -164,6 +165,27 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
assert new_mem != "not_member"
|
assert new_mem != "not_member"
|
||||||
assert new_mem.role == 5
|
assert new_mem.role == 5
|
||||||
|
|
||||||
|
def test_update_change_club_groups(self):
|
||||||
|
"""Test that `update` set the user groups accordingly."""
|
||||||
|
user = baker.make(User)
|
||||||
|
membership = baker.make(Membership, end_date=None, user=user, role=5)
|
||||||
|
members_group = membership.club.members_group
|
||||||
|
board_group = membership.club.board_group
|
||||||
|
assert user.groups.contains(members_group)
|
||||||
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
|
user.memberships.update(role=1) # from board to simple member
|
||||||
|
assert user.groups.contains(members_group)
|
||||||
|
assert not user.groups.contains(board_group)
|
||||||
|
|
||||||
|
user.memberships.update(role=5) # from member to board
|
||||||
|
assert user.groups.contains(members_group)
|
||||||
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
|
user.memberships.update(end_date=localdate()) # end the membership
|
||||||
|
assert not user.groups.contains(members_group)
|
||||||
|
assert not user.groups.contains(board_group)
|
||||||
|
|
||||||
def test_delete_invalidate_cache(self):
|
def test_delete_invalidate_cache(self):
|
||||||
"""Test that the `delete` queryset properly invalidate cache."""
|
"""Test that the `delete` queryset properly invalidate cache."""
|
||||||
mem_skia = self.skia.memberships.get(club=self.club)
|
mem_skia = self.skia.memberships.get(club=self.club)
|
||||||
@ -182,6 +204,19 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
)
|
)
|
||||||
assert cached_mem == "not_member"
|
assert cached_mem == "not_member"
|
||||||
|
|
||||||
|
def test_delete_remove_from_groups(self):
|
||||||
|
"""Test that `delete` removes from club groups"""
|
||||||
|
user = baker.make(User)
|
||||||
|
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
|
||||||
|
club_groups = {
|
||||||
|
memberships[0].club.members_group,
|
||||||
|
memberships[1].club.members_group,
|
||||||
|
memberships[1].club.board_group,
|
||||||
|
}
|
||||||
|
assert set(user.groups.all()) == club_groups
|
||||||
|
user.memberships.all().delete()
|
||||||
|
assert user.groups.all().count() == 0
|
||||||
|
|
||||||
|
|
||||||
class TestClubModel(TestClub):
|
class TestClubModel(TestClub):
|
||||||
def assert_membership_started_today(self, user: User, role: int):
|
def assert_membership_started_today(self, user: User, role: int):
|
||||||
@ -192,10 +227,8 @@ class TestClubModel(TestClub):
|
|||||||
assert membership.end_date is None
|
assert membership.end_date is None
|
||||||
assert membership.role == role
|
assert membership.role == role
|
||||||
assert membership.club.get_membership_for(user) == membership
|
assert membership.club.get_membership_for(user) == membership
|
||||||
member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
assert user.is_in_group(pk=self.club.members_group_id)
|
||||||
board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
assert user.is_in_group(pk=self.club.board_group_id)
|
||||||
assert user.is_in_group(name=member_group)
|
|
||||||
assert user.is_in_group(name=board_group)
|
|
||||||
|
|
||||||
def assert_membership_ended_today(self, user: User):
|
def assert_membership_ended_today(self, user: User):
|
||||||
"""Assert that the given user have a membership which ended today."""
|
"""Assert that the given user have a membership which ended today."""
|
||||||
@ -474,37 +507,35 @@ class TestClubModel(TestClub):
|
|||||||
assert self.club.members.count() == nb_memberships
|
assert self.club.members.count() == nb_memberships
|
||||||
assert membership == new_mem
|
assert membership == new_mem
|
||||||
|
|
||||||
def test_delete_remove_from_meta_group(self):
|
def test_remove_from_club_group(self):
|
||||||
"""Test that when a club is deleted, all its members are removed from the
|
"""Test that when a membership ends, the user is removed from club groups."""
|
||||||
associated metagroup.
|
user = baker.make(User)
|
||||||
"""
|
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
||||||
memberships = self.club.members.select_related("user")
|
assert user.groups.contains(self.club.members_group)
|
||||||
users = [membership.user for membership in memberships]
|
assert user.groups.contains(self.club.board_group)
|
||||||
meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
user.memberships.update(end_date=localdate())
|
||||||
|
assert not user.groups.contains(self.club.members_group)
|
||||||
|
assert not user.groups.contains(self.club.board_group)
|
||||||
|
|
||||||
self.club.delete()
|
def test_add_to_club_group(self):
|
||||||
for user in users:
|
"""Test that when a membership begins, the user is added to the club group."""
|
||||||
assert not user.is_in_group(name=meta_group)
|
assert not self.subscriber.groups.contains(self.club.members_group)
|
||||||
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
|
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
|
||||||
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
|
|
||||||
def test_add_to_meta_group(self):
|
def test_change_position_in_club(self):
|
||||||
"""Test that when a membership begins, the user is added to the meta group."""
|
"""Test that when moving from board to members, club group change"""
|
||||||
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
membership = baker.make(
|
||||||
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
Membership, club=self.club, user=self.subscriber, role=3
|
||||||
assert not self.subscriber.is_in_group(name=group_members)
|
)
|
||||||
assert not self.subscriber.is_in_group(name=board_members)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
Membership.objects.create(club=self.club, user=self.subscriber, role=3)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
assert self.subscriber.is_in_group(name=group_members)
|
membership.role = 1
|
||||||
assert self.subscriber.is_in_group(name=board_members)
|
membership.save()
|
||||||
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
def test_remove_from_meta_group(self):
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
"""Test that when a membership ends, the user is removed from meta group."""
|
|
||||||
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
|
||||||
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
|
||||||
assert self.comptable.is_in_group(name=group_members)
|
|
||||||
assert self.comptable.is_in_group(name=board_members)
|
|
||||||
self.comptable.memberships.update(end_date=localtime(now()))
|
|
||||||
assert not self.comptable.is_in_group(name=group_members)
|
|
||||||
assert not self.comptable.is_in_group(name=board_members)
|
|
||||||
|
|
||||||
def test_club_owner(self):
|
def test_club_owner(self):
|
||||||
"""Test that a club is owned only by board members of the main club."""
|
"""Test that a club is owned only by board members of the main club."""
|
||||||
@ -517,6 +548,26 @@ class TestClubModel(TestClub):
|
|||||||
Membership(club=self.ae, user=self.sli, role=3).save()
|
Membership(club=self.ae, user=self.sli, role=3).save()
|
||||||
assert self.club.is_owned_by(self.sli)
|
assert self.club.is_owned_by(self.sli)
|
||||||
|
|
||||||
|
def test_change_club_name(self):
|
||||||
|
"""Test that changing the club name doesn't break things."""
|
||||||
|
members_group = self.club.members_group
|
||||||
|
board_group = self.club.board_group
|
||||||
|
initial_members = set(members_group.users.values_list("id", flat=True))
|
||||||
|
initial_board = set(board_group.users.values_list("id", flat=True))
|
||||||
|
self.club.name = "something else"
|
||||||
|
self.club.save()
|
||||||
|
self.club.refresh_from_db()
|
||||||
|
|
||||||
|
# The names should have changed, but not the ids nor the group members
|
||||||
|
assert self.club.members_group.name == "something else - Membres"
|
||||||
|
assert self.club.board_group.name == "something else - Bureau"
|
||||||
|
assert self.club.members_group.id == members_group.id
|
||||||
|
assert self.club.board_group.id == board_group.id
|
||||||
|
new_members = set(self.club.members_group.users.values_list("id", flat=True))
|
||||||
|
new_board = set(self.club.board_group.users.values_list("id", flat=True))
|
||||||
|
assert new_members == initial_members
|
||||||
|
assert new_board == initial_board
|
||||||
|
|
||||||
|
|
||||||
class TestMailingForm(TestCase):
|
class TestMailingForm(TestCase):
|
||||||
"""Perform validation tests for MailingForm."""
|
"""Perform validation tests for MailingForm."""
|
||||||
|
@ -71,14 +71,13 @@ class ClubTabsMixin(TabedViewMixin):
|
|||||||
return self.object.get_display_name()
|
return self.object.get_display_name()
|
||||||
|
|
||||||
def get_list_of_tabs(self):
|
def get_list_of_tabs(self):
|
||||||
tab_list = []
|
tab_list = [
|
||||||
tab_list.append(
|
|
||||||
{
|
{
|
||||||
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
|
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
|
||||||
"slug": "infos",
|
"slug": "infos",
|
||||||
"name": _("Infos"),
|
"name": _("Infos"),
|
||||||
}
|
}
|
||||||
)
|
]
|
||||||
if self.request.user.can_view(self.object):
|
if self.request.user.can_view(self.object):
|
||||||
tab_list.append(
|
tab_list.append(
|
||||||
{
|
{
|
||||||
|
32
com/api.py
Normal file
32
com/api.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import Http404
|
||||||
|
from ninja_extra import ControllerBase, api_controller, route
|
||||||
|
|
||||||
|
from com.calendar import IcsCalendar
|
||||||
|
from core.views.files import send_raw_file
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/calendar")
|
||||||
|
class CalendarController(ControllerBase):
|
||||||
|
CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
|
||||||
|
|
||||||
|
@route.get("/external.ics", url_name="calendar_external")
|
||||||
|
def calendar_external(self):
|
||||||
|
"""Return the ICS file of the AE Google Calendar
|
||||||
|
|
||||||
|
Because of Google's cors rules, we can't just do a request to google ics
|
||||||
|
from the frontend. Google is blocking CORS request in it's responses headers.
|
||||||
|
The only way to do it from the frontend is to use Google Calendar API with an API key
|
||||||
|
This is not especially desirable as your API key is going to be provided to the frontend.
|
||||||
|
|
||||||
|
This is why we have this backend based solution.
|
||||||
|
"""
|
||||||
|
if (calendar := IcsCalendar.get_external()) is not None:
|
||||||
|
return send_raw_file(calendar)
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
@route.get("/internal.ics", url_name="calendar_internal")
|
||||||
|
def calendar_internal(self):
|
||||||
|
return send_raw_file(IcsCalendar.get_internal())
|
9
com/apps.py
Normal file
9
com/apps.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ComConfig(AppConfig):
|
||||||
|
name = "com"
|
||||||
|
verbose_name = "News and communication"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import com.signals # noqa F401
|
76
com/calendar.py
Normal file
76
com/calendar.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import final
|
||||||
|
|
||||||
|
import urllib3
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from ical.calendar import Calendar
|
||||||
|
from ical.calendar_stream import IcsCalendarStream
|
||||||
|
from ical.event import Event
|
||||||
|
|
||||||
|
from com.models import NewsDate
|
||||||
|
|
||||||
|
|
||||||
|
@final
|
||||||
|
class IcsCalendar:
|
||||||
|
_CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
|
||||||
|
_EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics"
|
||||||
|
_INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None:
|
||||||
|
if (
|
||||||
|
cls._EXTERNAL_CALENDAR.exists()
|
||||||
|
and timezone.make_aware(
|
||||||
|
datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime)
|
||||||
|
)
|
||||||
|
+ expiration
|
||||||
|
> timezone.now()
|
||||||
|
):
|
||||||
|
return cls._EXTERNAL_CALENDAR
|
||||||
|
return cls.make_external()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def make_external(cls) -> Path | None:
|
||||||
|
calendar = urllib3.request(
|
||||||
|
"GET",
|
||||||
|
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics",
|
||||||
|
)
|
||||||
|
if calendar.status != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(cls._EXTERNAL_CALENDAR, "wb") as f:
|
||||||
|
_ = f.write(calendar.data)
|
||||||
|
return cls._EXTERNAL_CALENDAR
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_internal(cls) -> Path:
|
||||||
|
if not cls._INTERNAL_CALENDAR.exists():
|
||||||
|
return cls.make_internal()
|
||||||
|
return cls._INTERNAL_CALENDAR
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def make_internal(cls) -> Path:
|
||||||
|
# Updated through a post_save signal on News in com.signals
|
||||||
|
calendar = Calendar()
|
||||||
|
for news_date in NewsDate.objects.filter(
|
||||||
|
news__is_moderated=True,
|
||||||
|
end_date__gte=timezone.now() - (relativedelta(months=6)),
|
||||||
|
).prefetch_related("news"):
|
||||||
|
event = Event(
|
||||||
|
summary=news_date.news.title,
|
||||||
|
start=news_date.start_date,
|
||||||
|
end=news_date.end_date,
|
||||||
|
url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
|
||||||
|
)
|
||||||
|
calendar.events.append(event)
|
||||||
|
|
||||||
|
# Create a file so we can offload the download to the reverse proxy if available
|
||||||
|
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(cls._INTERNAL_CALENDAR, "wb") as f:
|
||||||
|
_ = f.write(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8"))
|
||||||
|
return cls._INTERNAL_CALENDAR
|
@ -0,0 +1,56 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2024-12-16 14:51
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("club", "0011_auto_20180426_2013"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("com", "0006_remove_sith_index_page"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="news",
|
||||||
|
name="club",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text="The club which organizes the event.",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="news",
|
||||||
|
to="club.club",
|
||||||
|
verbose_name="club",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="news",
|
||||||
|
name="content",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="A more detailed and exhaustive description of the event.",
|
||||||
|
verbose_name="content",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="news",
|
||||||
|
name="moderator",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="moderated_news",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="moderator",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="news",
|
||||||
|
name="summary",
|
||||||
|
field=models.TextField(
|
||||||
|
help_text="A description of the event (what is the activity ? is there an associated clic ? is there a inscription form ?)",
|
||||||
|
verbose_name="summary",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -17,11 +17,12 @@
|
|||||||
# details.
|
# details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU General Public License along with
|
||||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
@ -34,7 +35,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.models import Notification, Preferences, RealGroup, User
|
from core.models import Notification, Preferences, User
|
||||||
|
|
||||||
|
|
||||||
class Sith(models.Model):
|
class Sith(models.Model):
|
||||||
@ -62,16 +63,31 @@ NEWS_TYPES = [
|
|||||||
|
|
||||||
|
|
||||||
class News(models.Model):
|
class News(models.Model):
|
||||||
"""The news class."""
|
"""News about club events."""
|
||||||
|
|
||||||
title = models.CharField(_("title"), max_length=64)
|
title = models.CharField(_("title"), max_length=64)
|
||||||
summary = models.TextField(_("summary"))
|
summary = models.TextField(
|
||||||
content = models.TextField(_("content"))
|
_("summary"),
|
||||||
|
help_text=_(
|
||||||
|
"A description of the event (what is the activity ? "
|
||||||
|
"is there an associated clic ? is there a inscription form ?)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
content = models.TextField(
|
||||||
|
_("content"),
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text=_("A more detailed and exhaustive description of the event."),
|
||||||
|
)
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
|
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
|
||||||
)
|
)
|
||||||
club = models.ForeignKey(
|
club = models.ForeignKey(
|
||||||
Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
|
Club,
|
||||||
|
related_name="news",
|
||||||
|
verbose_name=_("club"),
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
help_text=_("The club which organizes the event."),
|
||||||
)
|
)
|
||||||
author = models.ForeignKey(
|
author = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
@ -85,7 +101,7 @@ class News(models.Model):
|
|||||||
related_name="moderated_news",
|
related_name="moderated_news",
|
||||||
verbose_name=_("moderator"),
|
verbose_name=_("moderator"),
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -93,17 +109,15 @@ class News(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
for u in (
|
for user in User.objects.filter(
|
||||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
|
||||||
.first()
|
|
||||||
.users.all()
|
|
||||||
):
|
):
|
||||||
Notification(
|
Notification.objects.create(
|
||||||
user=u,
|
user=user,
|
||||||
url=reverse("com:news_admin_list"),
|
url=reverse("com:news_admin_list"),
|
||||||
type="NEWS_MODERATION",
|
type="NEWS_MODERATION",
|
||||||
param="1",
|
param="1",
|
||||||
).save()
|
)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("com:news_detail", kwargs={"news_id": self.id})
|
return reverse("com:news_detail", kwargs={"news_id": self.id})
|
||||||
@ -321,16 +335,14 @@ class Poster(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.is_moderated:
|
if not self.is_moderated:
|
||||||
for u in (
|
for user in User.objects.filter(
|
||||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
|
||||||
.first()
|
|
||||||
.users.all()
|
|
||||||
):
|
):
|
||||||
Notification(
|
Notification.objects.create(
|
||||||
user=u,
|
user=user,
|
||||||
url=reverse("com:poster_moderate_list"),
|
url=reverse("com:poster_moderate_list"),
|
||||||
type="POSTER_MODERATION",
|
type="POSTER_MODERATION",
|
||||||
).save()
|
)
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
def clean(self, *args, **kwargs):
|
def clean(self, *args, **kwargs):
|
||||||
|
10
com/signals.py
Normal file
10
com/signals.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.db.models.signals import post_delete, post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from com.calendar import IcsCalendar
|
||||||
|
from com.models import News
|
||||||
|
|
||||||
|
|
||||||
|
@receiver([post_save, post_delete], sender=News, dispatch_uid="update_internal_ics")
|
||||||
|
def update_internal_ics(*args, **kwargs):
|
||||||
|
_ = IcsCalendar.make_internal()
|
194
com/static/bundled/com/components/ics-calendar-index.ts
Normal file
194
com/static/bundled/com/components/ics-calendar-index.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { makeUrl } from "#core:utils/api";
|
||||||
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||||
|
import { Calendar, type EventClickArg } from "@fullcalendar/core";
|
||||||
|
import type { EventImpl } from "@fullcalendar/core/internal";
|
||||||
|
import enLocale from "@fullcalendar/core/locales/en-gb";
|
||||||
|
import frLocale from "@fullcalendar/core/locales/fr";
|
||||||
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
|
import iCalendarPlugin from "@fullcalendar/icalendar";
|
||||||
|
import listPlugin from "@fullcalendar/list";
|
||||||
|
import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi";
|
||||||
|
|
||||||
|
@registerComponent("ics-calendar")
|
||||||
|
export class IcsCalendar extends inheritHtmlElement("div") {
|
||||||
|
static observedAttributes = ["locale"];
|
||||||
|
private calendar: Calendar;
|
||||||
|
private locale = "en";
|
||||||
|
|
||||||
|
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||||
|
if (name !== "locale") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.locale = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
isMobile() {
|
||||||
|
return window.innerWidth < 765;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentView() {
|
||||||
|
// Get view type based on viewport
|
||||||
|
return this.isMobile() ? "listMonth" : "dayGridMonth";
|
||||||
|
}
|
||||||
|
|
||||||
|
currentToolbar() {
|
||||||
|
if (this.isMobile()) {
|
||||||
|
return {
|
||||||
|
left: "prev,next",
|
||||||
|
center: "title",
|
||||||
|
right: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
left: "prev,next today",
|
||||||
|
center: "title",
|
||||||
|
right: "dayGridMonth,dayGridWeek,dayGridDay",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat(this.locale, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
createEventDetailPopup(event: EventClickArg) {
|
||||||
|
// Delete previous popup
|
||||||
|
const oldPopup = document.getElementById("event-details");
|
||||||
|
if (oldPopup !== null) {
|
||||||
|
oldPopup.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const makePopupInfo = (info: HTMLElement, iconClass: string) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const icon = document.createElement("i");
|
||||||
|
|
||||||
|
row.setAttribute("class", "event-details-row");
|
||||||
|
|
||||||
|
icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`);
|
||||||
|
|
||||||
|
row.appendChild(icon);
|
||||||
|
row.appendChild(info);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makePopupTitle = (event: EventImpl) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.innerHTML = `
|
||||||
|
<h4 class="event-details-row-content">
|
||||||
|
${event.title}
|
||||||
|
</h4>
|
||||||
|
<span class="event-details-row-content">
|
||||||
|
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
return makePopupInfo(
|
||||||
|
row,
|
||||||
|
"fa-solid fa-calendar-days fa-xl event-detail-row-icon",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makePopupLocation = (event: EventImpl) => {
|
||||||
|
if (event.extendedProps.location === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.innerText = event.extendedProps.location;
|
||||||
|
|
||||||
|
return makePopupInfo(info, "fa-solid fa-location-dot");
|
||||||
|
};
|
||||||
|
|
||||||
|
const makePopupUrl = (event: EventImpl) => {
|
||||||
|
if (event.url === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const url = document.createElement("a");
|
||||||
|
url.href = event.url;
|
||||||
|
url.textContent = gettext("More info");
|
||||||
|
|
||||||
|
return makePopupInfo(url, "fa-solid fa-link");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new popup
|
||||||
|
const popup = document.createElement("div");
|
||||||
|
const popupContainer = document.createElement("div");
|
||||||
|
|
||||||
|
popup.setAttribute("id", "event-details");
|
||||||
|
popupContainer.setAttribute("class", "event-details-container");
|
||||||
|
|
||||||
|
popupContainer.appendChild(makePopupTitle(event.event));
|
||||||
|
|
||||||
|
const location = makePopupLocation(event.event);
|
||||||
|
if (location !== null) {
|
||||||
|
popupContainer.appendChild(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = makePopupUrl(event.event);
|
||||||
|
if (url !== null) {
|
||||||
|
popupContainer.appendChild(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
popup.appendChild(popupContainer);
|
||||||
|
|
||||||
|
// We can't just add the element relative to the one we want to appear under
|
||||||
|
// Otherwise, it either gets clipped by the boundaries of the calendar or resize cells
|
||||||
|
// Here, we create a popup outside the calendar that follows the clicked element
|
||||||
|
this.node.appendChild(popup);
|
||||||
|
const follow = (node: HTMLElement) => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
popup.setAttribute(
|
||||||
|
"style",
|
||||||
|
`top: calc(${rect.top + window.scrollY}px + ${rect.height}px); left: ${rect.left + window.scrollX}px;`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
follow(event.el);
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
follow(event.el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.calendar = new Calendar(this.node, {
|
||||||
|
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
|
||||||
|
locales: [frLocale, enLocale],
|
||||||
|
height: "auto",
|
||||||
|
locale: this.locale,
|
||||||
|
initialView: this.currentView(),
|
||||||
|
headerToolbar: this.currentToolbar(),
|
||||||
|
eventSources: [
|
||||||
|
{
|
||||||
|
url: await makeUrl(calendarCalendarInternal),
|
||||||
|
format: "ics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: await makeUrl(calendarCalendarExternal),
|
||||||
|
format: "ics",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
windowResize: () => {
|
||||||
|
this.calendar.changeView(this.currentView());
|
||||||
|
this.calendar.setOption("headerToolbar", this.currentToolbar());
|
||||||
|
},
|
||||||
|
eventClick: (event) => {
|
||||||
|
// Avoid our popup to be deleted because we clicked outside of it
|
||||||
|
event.jsEvent.stopPropagation();
|
||||||
|
// Don't auto-follow events URLs
|
||||||
|
event.jsEvent.preventDefault();
|
||||||
|
this.createEventDetailPopup(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.calendar.render();
|
||||||
|
|
||||||
|
window.addEventListener("click", (event: MouseEvent) => {
|
||||||
|
// Auto close popups when clicking outside of it
|
||||||
|
const popup = document.getElementById("event-details");
|
||||||
|
if (popup !== null && !popup.contains(event.target as Node)) {
|
||||||
|
popup.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
101
com/static/com/components/ics-calendar.scss
Normal file
101
com/static/com/components/ics-calendar.scss
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
@import "core/static/core/colors";
|
||||||
|
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--fc-button-border-color: #fff;
|
||||||
|
--fc-button-hover-border-color: #fff;
|
||||||
|
--fc-button-active-border-color: #fff;
|
||||||
|
--fc-button-text-color: #fff;
|
||||||
|
--fc-button-bg-color: #1a78b3;
|
||||||
|
--fc-button-active-bg-color: #15608F;
|
||||||
|
--fc-button-hover-bg-color: #15608F;
|
||||||
|
--fc-today-bg-color: rgba(26, 120, 179, 0.1);
|
||||||
|
--fc-border-color: #DDDDDD;
|
||||||
|
--event-details-background-color: white;
|
||||||
|
--event-details-padding: 20px;
|
||||||
|
--event-details-border: 1px solid #EEEEEE;
|
||||||
|
--event-details-border-radius: 4px;
|
||||||
|
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
||||||
|
--event-details-max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ics-calendar {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
#event-details {
|
||||||
|
z-index: 10;
|
||||||
|
max-width: 1151px;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
.event-details-container {
|
||||||
|
display: flex;
|
||||||
|
color: black;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: var(--event-details-max-width);
|
||||||
|
padding: var(--event-details-padding);
|
||||||
|
border: var(--event-details-border);
|
||||||
|
border-radius: var(--event-details-border-radius);
|
||||||
|
background-color: var(--event-details-background-color);
|
||||||
|
box-shadow: var(--event-details-box-shadow);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-detail-row-icon {
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 20px;
|
||||||
|
align-content: center;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-details-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-details-row-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: row;
|
||||||
|
background-color: var(--event-details-background-color);
|
||||||
|
margin-top: 0px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.fc-col-header-cell-cushion,
|
||||||
|
a.fc-col-header-cell-cushion:hover {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.fc-daygrid-day-number,
|
||||||
|
a.fc-daygrid-day-number:hover {
|
||||||
|
color: rgb(34, 34, 34);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
overflow-x: visible; // Show events on multiple days
|
||||||
|
}
|
||||||
|
|
||||||
|
//Reset from style.scss
|
||||||
|
table {
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0px;
|
||||||
|
-moz-border-radius: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset from style.scss
|
||||||
|
thead {
|
||||||
|
background-color: white;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset from style.scss
|
||||||
|
tbody>tr {
|
||||||
|
&:nth-child(even):not(.highlight) {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
com/static/com/css/news-detail.scss
Normal file
61
com/static/com/css/news-detail.scss
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
@import "core/static/core/colors";
|
||||||
|
|
||||||
|
#news_details {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 0.4em;
|
||||||
|
width: 80%;
|
||||||
|
background: $white-color;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-top: 1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.club_logo {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
width: 19%;
|
||||||
|
float: left;
|
||||||
|
min-width: 15em;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-height: 15em;
|
||||||
|
max-width: 12em;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share_button {
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.2em;
|
||||||
|
border-radius: 2px;
|
||||||
|
float: right;
|
||||||
|
display: block;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: lightgrey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.facebook {
|
||||||
|
background: $faceblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitter {
|
||||||
|
background: $twitblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_meta {
|
||||||
|
margin-top: 10em;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
}
|
297
com/static/com/css/news-list.scss
Normal file
297
com/static/com/css/news-list.scss
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
@import "core/static/core/colors";
|
||||||
|
@import "core/static/core/devices";
|
||||||
|
|
||||||
|
#news {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#news_admin {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#right_column {
|
||||||
|
flex: 20%;
|
||||||
|
margin: 3.2px;
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#left_column {
|
||||||
|
flex: 79%;
|
||||||
|
margin: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
background: $second-color;
|
||||||
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
|
padding: 0.4em;
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 17px;
|
||||||
|
|
||||||
|
&:not(:first-of-type) {
|
||||||
|
margin: 2em 0 1em 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: $small-devices) {
|
||||||
|
|
||||||
|
#left_column,
|
||||||
|
#right_column {
|
||||||
|
flex: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LINKS/BIRTHDAYS */
|
||||||
|
#links,
|
||||||
|
#birthdays {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
font-size: 70%;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links_content {
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
|
height: 20em;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 10px;
|
||||||
|
|
||||||
|
.fa-facebook {
|
||||||
|
color: $faceblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-discord {
|
||||||
|
color: $discordblurple;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-square-instagram::before {
|
||||||
|
background: $instagradient;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
width: 25px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#birthdays_content {
|
||||||
|
ul.birthdays_year {
|
||||||
|
margin: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
>li {
|
||||||
|
padding: 0.5em;
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background: $secondary-neutral-light-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 1em;
|
||||||
|
list-style-type: square;
|
||||||
|
list-style-position: inside;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* END AGENDA/BIRTHDAYS */
|
||||||
|
|
||||||
|
/* EVENTS TODAY AND NEXT FEW DAYS */
|
||||||
|
.news_events_group {
|
||||||
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
|
margin-left: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
|
||||||
|
.news_events_group_date {
|
||||||
|
display: table-cell;
|
||||||
|
padding: 0.6em;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: $primary-neutral-dark-color;
|
||||||
|
color: $white-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.4em;
|
||||||
|
border-radius: 7px 0 0 7px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
.day {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_events_group_items {
|
||||||
|
display: table-cell;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.news_event:nth-of-type(odd) {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_event:nth-of-type(even) {
|
||||||
|
background: $primary-neutral-light-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_event {
|
||||||
|
display: block;
|
||||||
|
padding: 0.4em;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
margin: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-top: 1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.club_logo {
|
||||||
|
float: left;
|
||||||
|
min-width: 7em;
|
||||||
|
max-width: 9em;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 1em;
|
||||||
|
margin-top: 0.8em;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-height: 6em;
|
||||||
|
max-width: 8em;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_date {
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_content {
|
||||||
|
clear: left;
|
||||||
|
|
||||||
|
.button_bar {
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
.fb {
|
||||||
|
color: $faceblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twitter {
|
||||||
|
color: $twitblue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* END EVENTS TODAY AND NEXT FEW DAYS */
|
||||||
|
|
||||||
|
/* COMING SOON */
|
||||||
|
.news_coming_soon {
|
||||||
|
display: list-item;
|
||||||
|
list-style-type: square;
|
||||||
|
list-style-position: inside;
|
||||||
|
margin-left: 1em;
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_date {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* END COMING SOON */
|
||||||
|
|
||||||
|
/* NOTICES */
|
||||||
|
.news_notice {
|
||||||
|
margin: 0 0 1em 1em;
|
||||||
|
padding: 0.4em;
|
||||||
|
padding-left: 1em;
|
||||||
|
background: $secondary-neutral-light-color;
|
||||||
|
box-shadow: $shadow-color 0 0 2px;
|
||||||
|
border-radius: 18px 5px 18px 5px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_content {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* END NOTICES */
|
||||||
|
|
||||||
|
/* CALLS */
|
||||||
|
.news_call {
|
||||||
|
margin: 0 0 1em 1em;
|
||||||
|
padding: 0.4em;
|
||||||
|
padding-left: 1em;
|
||||||
|
background: $secondary-neutral-light-color;
|
||||||
|
border: 1px solid grey;
|
||||||
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_date {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_content {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* END CALLS */
|
||||||
|
|
||||||
|
.news_empty {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news_date {
|
||||||
|
color: grey;
|
||||||
|
}
|
||||||
|
}
|
230
com/static/com/css/posters.scss
Normal file
230
com/static/com/css/posters.scss
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
#poster_list,
|
||||||
|
#screen_list,
|
||||||
|
#poster_edit,
|
||||||
|
#screen_edit {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
#title {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px;
|
||||||
|
border-bottom: 2px solid black;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
bottom: 5px;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
padding: 5px;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
margin-left: 5px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: hsl(40, 100%, 50%);
|
||||||
|
color: black;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: black;
|
||||||
|
background-color: hsl(40, 58%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.delete {
|
||||||
|
background-color: hsl(0, 100%, 40%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#posters,
|
||||||
|
#screens {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
#no-posters,
|
||||||
|
#no-screens {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster,
|
||||||
|
.screen {
|
||||||
|
min-width: 10%;
|
||||||
|
max-width: 20%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 10px;
|
||||||
|
border: 2px solid darkgrey;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: lightgrey;
|
||||||
|
|
||||||
|
* {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
padding-bottom: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border-bottom: 1px solid whitesmoke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border-bottom: 1px solid whitesmoke;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-height: 20vw;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
content: "Click to expand";
|
||||||
|
color: white;
|
||||||
|
background-color: rgba(black, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dates {
|
||||||
|
padding-bottom: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border-bottom: 1px solid whitesmoke;
|
||||||
|
|
||||||
|
* {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.begin,
|
||||||
|
.end {
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.begin {
|
||||||
|
border-right: 1px solid whitesmoke;
|
||||||
|
padding-right: 2%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit,
|
||||||
|
.moderate,
|
||||||
|
.slideshow {
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: hsl(40, 100%, 50%);
|
||||||
|
color: black;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: black;
|
||||||
|
background-color: hsl(40, 58%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2n) {
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
visibility: hidden;
|
||||||
|
width: 120px;
|
||||||
|
background-color: hsl(210, 20%, 98%);
|
||||||
|
color: hsl(0, 0%, 0%);
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin-left: 0;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: list-item;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.not_moderated {
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .tooltip {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#view {
|
||||||
|
position: fixed;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
visibility: hidden;
|
||||||
|
background-color: rgba(10, 10, 10, 0.9);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
#placeholder {
|
||||||
|
width: 80vw;
|
||||||
|
height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,11 @@
|
|||||||
{{ gen_news_metatags(news) }}
|
{{ gen_news_metatags(news) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
|
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
|
||||||
<section id="news_details">
|
<section id="news_details">
|
||||||
|
@ -34,43 +34,90 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.non_field_errors() }}
|
{{ form.non_field_errors() }}
|
||||||
{{ form.author }}
|
{{ form.author }}
|
||||||
<p>{{ form.type.errors }}<label for="{{ form.type.name }}">{{ form.type.label }}</label>
|
<p>
|
||||||
|
{{ form.type.errors }}
|
||||||
|
<label for="{{ form.type.name }}" class="required">{{ form.type.label }}</label>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
|
<li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
|
||||||
<li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
|
<li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
|
||||||
<li>{% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}</li>
|
<li>
|
||||||
<li>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
|
{% trans trimmed%}
|
||||||
|
Weekly: recurrent event, associated with many dates
|
||||||
|
(specify the first one, and a deadline)
|
||||||
|
{% endtrans %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{% trans trimmed %}
|
||||||
|
Call: long time event, associated with a long date (like election appliance)
|
||||||
|
{% endtrans %}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{ form.type }}</p>
|
{{ form.type }}
|
||||||
<p class="date">{{ form.start_date.errors }}<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label> {{ form.start_date }}</p>
|
</p>
|
||||||
<p class="date">{{ form.end_date.errors }}<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label> {{ form.end_date }}</p>
|
<p class="date">
|
||||||
<p class="until">{{ form.until.errors }}<label for="{{ form.until.name }}">{{ form.until.label }}</label> {{ form.until }}</p>
|
{{ form.start_date.errors }}
|
||||||
<p>{{ form.title.errors }}<label for="{{ form.title.name }}">{{ form.title.label }}</label> {{ form.title }}</p>
|
<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
|
||||||
<p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
|
{{ form.start_date }}
|
||||||
<p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
|
</p>
|
||||||
<p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
|
<p class="date">
|
||||||
|
{{ form.end_date.errors }}
|
||||||
|
<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label>
|
||||||
|
{{ form.end_date }}
|
||||||
|
</p>
|
||||||
|
<p class="until">
|
||||||
|
{{ form.until.errors }}
|
||||||
|
<label for="{{ form.until.name }}">{{ form.until.label }}</label>
|
||||||
|
{{ form.until }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ form.title.errors }}
|
||||||
|
<label for="{{ form.title.name }}" class="required">{{ form.title.label }}</label>
|
||||||
|
{{ form.title }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ form.club.errors }}
|
||||||
|
<label for="{{ form.club.name }}" class="required">{{ form.club.label }}</label>
|
||||||
|
<span class="helptext">{{ form.club.help_text }}</span>
|
||||||
|
{{ form.club }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ form.summary.errors }}
|
||||||
|
<label for="{{ form.summary.name }}" class="required">{{ form.summary.label }}</label>
|
||||||
|
<span class="helptext">{{ form.summary.help_text }}</span>
|
||||||
|
{{ form.summary }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ form.content.errors }}
|
||||||
|
<label for="{{ form.content.name }}">{{ form.content.label }}</label>
|
||||||
|
<span class="helptext">{{ form.content.help_text }}</span>
|
||||||
|
{{ form.content }}
|
||||||
|
</p>
|
||||||
{% if user.is_com_admin %}
|
{% if user.is_com_admin %}
|
||||||
<p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
|
<p>
|
||||||
{{ form.automoderation }}</p>
|
{{ form.automoderation.errors }}
|
||||||
|
<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
|
||||||
|
{{ form.automoderation }}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}" /></p>
|
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
|
||||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
<p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
$( function() {
|
$(function () {
|
||||||
var type = $('input[name=type]');
|
let type = $('input[name=type]');
|
||||||
var dates = $('.date');
|
let dates = $('.date');
|
||||||
var until = $('.until');
|
let until = $('.until');
|
||||||
function update_targets () {
|
|
||||||
type_checked = $('input[name=type]:checked');
|
function update_targets() {
|
||||||
if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") {
|
const type_checked = $('input[name=type]:checked');
|
||||||
|
if (["CALL", "EVENT"].includes(type_checked.val())) {
|
||||||
dates.show();
|
dates.show();
|
||||||
until.hide();
|
until.hide();
|
||||||
} else if (type_checked.val() == "WEEKLY") {
|
} else if (type_checked.val() === "WEEKLY") {
|
||||||
dates.show();
|
dates.show();
|
||||||
until.show();
|
until.show();
|
||||||
} else {
|
} else {
|
||||||
@ -78,9 +125,10 @@
|
|||||||
until.hide();
|
until.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update_targets();
|
update_targets();
|
||||||
type.change(update_targets);
|
type.change(update_targets);
|
||||||
} );
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -5,6 +5,15 @@
|
|||||||
{% trans %}News{% endtrans %}
|
{% trans %}News{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
|
||||||
|
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_js %}
|
||||||
|
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if user.is_com_admin %}
|
{% if user.is_com_admin %}
|
||||||
<div id="news_admin">
|
<div id="news_admin">
|
||||||
@ -83,84 +92,78 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% set coming_soon = object_list.filter(dates__start_date__gte=timezone.now()+timedelta(days=5),
|
|
||||||
type="EVENT").order_by('dates__start_date') %}
|
|
||||||
{% if coming_soon %}
|
|
||||||
<h3>{% trans %}Coming soon... don't miss!{% endtrans %}</h3>
|
|
||||||
{% for news in coming_soon %}
|
|
||||||
<section class="news_coming_soon">
|
|
||||||
<a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a>
|
|
||||||
<span class="news_date">{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
|
|
||||||
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
|
|
||||||
{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
|
|
||||||
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
|
|
||||||
</section>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<h3>{% trans %}All coming events{% endtrans %}</h3>
|
<h3>{% trans %}All coming events{% endtrans %}</h3>
|
||||||
<iframe
|
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
|
||||||
src="https://embed.styledcalendar.com/#2mF2is8CEXhr4ADcX6qN"
|
|
||||||
title="Styled Calendar"
|
|
||||||
class="styled-calendar-container"
|
|
||||||
style="width: 100%; border: none; height: 1060px"
|
|
||||||
data-cy="calendar-embed-iframe">
|
|
||||||
</iframe>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="right_column" class="news_column">
|
<div id="right_column">
|
||||||
<div id="agenda">
|
<div id="links">
|
||||||
<div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
|
<h3>{% trans %}Links{% endtrans %}</h3>
|
||||||
<div id="agenda_content">
|
<div id="links_content">
|
||||||
{% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
|
<h4>{% trans %}Our services{% endtrans %}</h4>
|
||||||
news__is_moderated=True, news__type__in=["WEEKLY",
|
<ul>
|
||||||
"EVENT"]).order_by('start_date', 'end_date') %}
|
<li>
|
||||||
<div class="agenda_item">
|
<i class="fa-solid fa-graduation-cap fa-xl"></i>
|
||||||
<div class="agenda_date">
|
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
|
||||||
<strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
|
</li>
|
||||||
</div>
|
<li>
|
||||||
<div class="agenda_time">
|
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
||||||
<span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
|
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||||
<span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
|
</li>
|
||||||
</div>
|
<li>
|
||||||
<div>
|
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||||
<strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
|
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
|
||||||
<a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
<div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
|
<br>
|
||||||
</div>
|
<h4>{% trans %}Social media{% endtrans %}</h4>
|
||||||
{% endfor %}
|
<ul>
|
||||||
|
<li>
|
||||||
|
<i class="fa-brands fa-discord fa-xl"></i>
|
||||||
|
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
|
||||||
|
{% if user.was_subscribed %}
|
||||||
|
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">{% trans %}Dev Team{% endtrans %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fa-brands fa-facebook fa-xl"></i>
|
||||||
|
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fa-brands fa-square-instagram fa-xl"></i>
|
||||||
|
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="birthdays">
|
<div id="birthdays">
|
||||||
<div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
|
<h3>{% trans %}Birthdays{% endtrans %}</h3>
|
||||||
<div id="birthdays_content">
|
<div id="birthdays_content">
|
||||||
{% if user.is_subscribed %}
|
{%- if user.was_subscribed -%}
|
||||||
{# Cache request for 1 hour #}
|
<ul class="birthdays_year">
|
||||||
{% cache 3600 "birthdays" %}
|
{%- for year, users in birthdays -%}
|
||||||
<ul class="birthdays_year">
|
<li>
|
||||||
{% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
|
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
|
||||||
<li>
|
<ul>
|
||||||
{% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
|
{%- for u in users -%}
|
||||||
<ul>
|
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
|
||||||
{% for u in birthdays.filter(date_of_birth__year=d.year) %}
|
{%- endfor -%}
|
||||||
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
|
</ul>
|
||||||
{% endfor %}
|
</li>
|
||||||
</ul>
|
{%- endfor -%}
|
||||||
</li>
|
</ul>
|
||||||
{% endfor %}
|
{%- else -%}
|
||||||
</ul>
|
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
|
||||||
{% endcache %}
|
{%- endif -%}
|
||||||
{% else %}
|
|
||||||
<p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,6 +10,10 @@
|
|||||||
{% trans %}Poster{% endtrans %}
|
{% trans %}Poster{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="poster_list">
|
<div id="poster_list">
|
||||||
|
|
||||||
|
@ -5,6 +5,10 @@
|
|||||||
<script src="{{ static('com/js/poster_list.js') }}"></script>
|
<script src="{{ static('com/js/poster_list.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="poster_list">
|
<div id="poster_list">
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>{% trans %}Slideshow{% endtrans %}</title>
|
<title>{% trans %}Slideshow{% endtrans %}</title>
|
||||||
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
||||||
<script type="module" src="{{ static('bundled/jquery-index.js') }}"></script>
|
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
|
||||||
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
122
com/tests/test_api.py
Normal file
122
com/tests/test_api.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test.client import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from com.calendar import IcsCalendar
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MockResponse:
|
||||||
|
status: int
|
||||||
|
value: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self):
|
||||||
|
return self.value.encode("utf8")
|
||||||
|
|
||||||
|
|
||||||
|
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
|
||||||
|
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
|
||||||
|
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):
|
||||||
|
return None
|
||||||
|
return settings.MEDIA_ROOT / redirect.relative_to(
|
||||||
|
Path("/") / settings.MEDIA_ROOT.stem
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestExternalCalendar:
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_request(self):
|
||||||
|
mock = MagicMock()
|
||||||
|
with patch("urllib3.request", mock):
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_current_time(self):
|
||||||
|
mock = MagicMock()
|
||||||
|
original = timezone.now
|
||||||
|
with patch("django.utils.timezone.now", mock):
|
||||||
|
yield mock, original
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_cache(self):
|
||||||
|
IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("error_code", [403, 404, 500])
|
||||||
|
def test_fetch_error(
|
||||||
|
self, client: Client, mock_request: MagicMock, error_code: int
|
||||||
|
):
|
||||||
|
mock_request.return_value = MockResponse(error_code, "not allowed")
|
||||||
|
assert client.get(reverse("api:calendar_external")).status_code == 404
|
||||||
|
|
||||||
|
def test_fetch_success(self, client: Client, mock_request: MagicMock):
|
||||||
|
external_response = MockResponse(200, "Definitely an ICS")
|
||||||
|
mock_request.return_value = external_response
|
||||||
|
response = client.get(reverse("api:calendar_external"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
out_file = accel_redirect_to_file(response)
|
||||||
|
assert out_file is not None
|
||||||
|
assert out_file.exists()
|
||||||
|
with open(out_file, "r") as f:
|
||||||
|
assert f.read() == external_response.value
|
||||||
|
|
||||||
|
def test_fetch_caching(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
mock_request: MagicMock,
|
||||||
|
mock_current_time: tuple[MagicMock, Callable[[], datetime]],
|
||||||
|
):
|
||||||
|
fake_current_time, original_timezone = mock_current_time
|
||||||
|
start_time = original_timezone()
|
||||||
|
|
||||||
|
fake_current_time.return_value = start_time
|
||||||
|
external_response = MockResponse(200, "Definitely an ICS")
|
||||||
|
mock_request.return_value = external_response
|
||||||
|
|
||||||
|
with open(
|
||||||
|
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
|
||||||
|
) as f:
|
||||||
|
assert f.read() == external_response.value
|
||||||
|
|
||||||
|
mock_request.return_value = MockResponse(200, "This should be ignored")
|
||||||
|
with open(
|
||||||
|
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
|
||||||
|
) as f:
|
||||||
|
assert f.read() == external_response.value
|
||||||
|
|
||||||
|
mock_request.assert_called_once()
|
||||||
|
|
||||||
|
fake_current_time.return_value = start_time + timedelta(hours=1, seconds=1)
|
||||||
|
external_response = MockResponse(200, "This won't be ignored")
|
||||||
|
mock_request.return_value = external_response
|
||||||
|
|
||||||
|
with open(
|
||||||
|
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
|
||||||
|
) as f:
|
||||||
|
assert f.read() == external_response.value
|
||||||
|
|
||||||
|
assert mock_request.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestInternalCalendar:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_cache(self):
|
||||||
|
IcsCalendar._INTERNAL_CALENDAR.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_fetch_success(self, client: Client):
|
||||||
|
response = client.get(reverse("api:calendar_internal"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
out_file = accel_redirect_to_file(response)
|
||||||
|
assert out_file is not None
|
||||||
|
assert out_file.exists()
|
@ -23,7 +23,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
|
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
|
||||||
from core.models import AnonymousUser, RealGroup, User
|
from core.models import AnonymousUser, Group, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@ -49,9 +49,7 @@ class TestCom(TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.skia = User.objects.get(username="skia")
|
cls.skia = User.objects.get(username="skia")
|
||||||
cls.com_group = RealGroup.objects.filter(
|
cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
id=settings.SITH_GROUP_COM_ADMIN_ID
|
|
||||||
).first()
|
|
||||||
cls.skia.groups.set([cls.com_group])
|
cls.skia.groups.set([cls.com_group])
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -99,9 +97,7 @@ class TestCom(TestCase):
|
|||||||
response = self.client.get(reverse("core:index"))
|
response = self.client.get(reverse("core:index"))
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
response,
|
response,
|
||||||
text=html.escape(
|
text=html.escape(_("You need to subscribe to access this content")),
|
||||||
_("You need an up to date subscription to access this content")
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_birthday_subscibed_user(self):
|
def test_birthday_subscibed_user(self):
|
||||||
@ -109,9 +105,16 @@ class TestCom(TestCase):
|
|||||||
|
|
||||||
self.assertNotContains(
|
self.assertNotContains(
|
||||||
response,
|
response,
|
||||||
text=html.escape(
|
text=html.escape(_("You need to subscribe to access this content")),
|
||||||
_("You need an up to date subscription to access this content")
|
)
|
||||||
),
|
|
||||||
|
def test_birthday_old_subscibed_user(self):
|
||||||
|
self.client.force_login(User.objects.get(username="old_subscriber"))
|
||||||
|
response = self.client.get(reverse("core:index"))
|
||||||
|
|
||||||
|
self.assertNotContains(
|
||||||
|
response,
|
||||||
|
text=html.escape(_("You need to subscribe to access this content")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
73
com/views.py
73
com/views.py
@ -21,14 +21,14 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
import itertools
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from smtplib import SMTPRecipientsRefused
|
from smtplib import SMTPRecipientsRefused
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.db.models import Max
|
from django.db.models import Exists, Max, OuterRef
|
||||||
from django.forms.models import modelform_factory
|
from django.forms.models import modelform_factory
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
@ -42,7 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
|||||||
|
|
||||||
from club.models import Club, Mailing
|
from club.models import Club, Mailing
|
||||||
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
|
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
|
||||||
from core.models import Notification, RealGroup, User
|
from core.models import Notification, User
|
||||||
from core.views import (
|
from core.views import (
|
||||||
CanCreateMixin,
|
CanCreateMixin,
|
||||||
CanEditMixin,
|
CanEditMixin,
|
||||||
@ -223,15 +223,13 @@ class NewsForm(forms.ModelForm):
|
|||||||
):
|
):
|
||||||
self.add_error(
|
self.add_error(
|
||||||
"end_date",
|
"end_date",
|
||||||
ValidationError(
|
ValidationError(_("An event cannot end before its beginning.")),
|
||||||
_("You crazy? You can not finish an event before starting it.")
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
|
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
|
||||||
self.add_error("until", ValidationError(_("This field is required.")))
|
self.add_error("until", ValidationError(_("This field is required.")))
|
||||||
return self.cleaned_data
|
return self.cleaned_data
|
||||||
|
|
||||||
def save(self):
|
def save(self, *args, **kwargs):
|
||||||
ret = super().save()
|
ret = super().save()
|
||||||
self.instance.dates.all().delete()
|
self.instance.dates.all().delete()
|
||||||
if self.instance.type == "EVENT" or self.instance.type == "CALL":
|
if self.instance.type == "EVENT" or self.instance.type == "CALL":
|
||||||
@ -280,21 +278,18 @@ class NewsEditView(CanEditMixin, UpdateView):
|
|||||||
else:
|
else:
|
||||||
self.object.is_moderated = False
|
self.object.is_moderated = False
|
||||||
self.object.save()
|
self.object.save()
|
||||||
for u in (
|
unread_notif_subquery = Notification.objects.filter(
|
||||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
|
||||||
.first()
|
)
|
||||||
.users.all()
|
for user in User.objects.filter(
|
||||||
|
~Exists(unread_notif_subquery),
|
||||||
|
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||||
):
|
):
|
||||||
if not u.notifications.filter(
|
Notification.objects.create(
|
||||||
type="NEWS_MODERATION", viewed=False
|
user=user,
|
||||||
).exists():
|
url=self.object.get_absolute_url(),
|
||||||
Notification(
|
type="NEWS_MODERATION",
|
||||||
user=u,
|
)
|
||||||
url=reverse(
|
|
||||||
"com:news_detail", kwargs={"news_id": self.object.id}
|
|
||||||
),
|
|
||||||
type="NEWS_MODERATION",
|
|
||||||
).save()
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
@ -325,19 +320,18 @@ class NewsCreateView(CanCreateMixin, CreateView):
|
|||||||
self.object.is_moderated = True
|
self.object.is_moderated = True
|
||||||
self.object.save()
|
self.object.save()
|
||||||
else:
|
else:
|
||||||
for u in (
|
unread_notif_subquery = Notification.objects.filter(
|
||||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
|
||||||
.first()
|
)
|
||||||
.users.all()
|
for user in User.objects.filter(
|
||||||
|
~Exists(unread_notif_subquery),
|
||||||
|
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||||
):
|
):
|
||||||
if not u.notifications.filter(
|
Notification.objects.create(
|
||||||
type="NEWS_MODERATION", viewed=False
|
user=user,
|
||||||
).exists():
|
url=reverse("com:news_admin_list"),
|
||||||
Notification(
|
type="NEWS_MODERATION",
|
||||||
user=u,
|
)
|
||||||
url=reverse("com:news_admin_list"),
|
|
||||||
type="NEWS_MODERATION",
|
|
||||||
).save()
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
@ -380,13 +374,14 @@ class NewsListView(CanViewMixin, ListView):
|
|||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["NewsDate"] = NewsDate
|
kwargs["NewsDate"] = NewsDate
|
||||||
kwargs["timedelta"] = timedelta
|
kwargs["timedelta"] = timedelta
|
||||||
kwargs["birthdays"] = (
|
kwargs["birthdays"] = itertools.groupby(
|
||||||
User.objects.filter(
|
User.objects.filter(
|
||||||
date_of_birth__month=localdate().month,
|
date_of_birth__month=localdate().month,
|
||||||
date_of_birth__day=localdate().day,
|
date_of_birth__day=localdate().day,
|
||||||
)
|
)
|
||||||
.filter(role__in=["STUDENT", "FORMER STUDENT"])
|
.filter(role__in=["STUDENT", "FORMER STUDENT"])
|
||||||
.order_by("-date_of_birth")
|
.order_by("-date_of_birth"),
|
||||||
|
key=lambda u: u.date_of_birth.year,
|
||||||
)
|
)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@ -690,8 +685,12 @@ class PosterEditBaseView(UpdateView):
|
|||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
return {
|
return {
|
||||||
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"),
|
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
|
if self.object.date_begin
|
||||||
|
else None,
|
||||||
|
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
if self.object.date_end
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
@ -15,17 +15,32 @@
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.models import Group as AuthGroup
|
from django.contrib.auth.models import Group as AuthGroup
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
|
||||||
from core.models import Group, OperationLog, Page, SithFile, User
|
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
|
||||||
|
|
||||||
admin.site.unregister(AuthGroup)
|
admin.site.unregister(AuthGroup)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Group)
|
@admin.register(Group)
|
||||||
class GroupAdmin(admin.ModelAdmin):
|
class GroupAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "description", "is_meta")
|
list_display = ("name", "description", "is_manually_manageable")
|
||||||
list_filter = ("is_meta",)
|
list_filter = ("is_manually_manageable",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
autocomplete_fields = ("permissions",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BanGroup)
|
||||||
|
class BanGroupAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "description")
|
||||||
|
search_fields = ("name",)
|
||||||
|
autocomplete_fields = ("permissions",)
|
||||||
|
|
||||||
|
|
||||||
|
class UserBanInline(admin.TabularInline):
|
||||||
|
model = UserBan
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ("ban_group",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
@ -37,10 +52,24 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
"profile_pict",
|
"profile_pict",
|
||||||
"avatar_pict",
|
"avatar_pict",
|
||||||
"scrub_pict",
|
"scrub_pict",
|
||||||
|
"user_permissions",
|
||||||
|
"groups",
|
||||||
)
|
)
|
||||||
|
inlines = (UserBanInline,)
|
||||||
search_fields = ["first_name", "last_name", "username"]
|
search_fields = ["first_name", "last_name", "username"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserBan)
|
||||||
|
class UserBanAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "ban_group", "created_at", "expires_at")
|
||||||
|
autocomplete_fields = ("user", "ban_group")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Permission)
|
||||||
|
class PermissionAdmin(admin.ModelAdmin):
|
||||||
|
search_fields = ("codename",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Page)
|
@admin.register(Page)
|
||||||
class PageAdmin(admin.ModelAdmin):
|
class PageAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "_full_name", "owner_group")
|
list_display = ("name", "_full_name", "owner_group")
|
||||||
|
42
core/auth_backends.py
Normal file
42
core/auth_backends.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.backends import ModelBackend
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
|
||||||
|
from core.models import Group
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class SithModelBackend(ModelBackend):
|
||||||
|
"""Custom auth backend for the Sith.
|
||||||
|
|
||||||
|
In fact, it's the exact same backend as `django.contrib.auth.backend.ModelBackend`,
|
||||||
|
with the exception that group permissions are fetched slightly differently.
|
||||||
|
Indeed, django tries by default to fetch the permissions associated
|
||||||
|
with all the `django.contrib.auth.models.Group` of a user ;
|
||||||
|
however, our User model overrides that, so the actual linked group model
|
||||||
|
is [core.models.Group][].
|
||||||
|
Instead of having the relation `auth_perm --> auth_group <-- core_user`,
|
||||||
|
we have `auth_perm --> auth_group <-- core_group <-- core_user`.
|
||||||
|
|
||||||
|
Thus, this backend make the small tweaks necessary to make
|
||||||
|
our custom models interact with the django auth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _get_group_permissions(self, user_obj: User):
|
||||||
|
# union of querysets doesn't work if the queryset is ordered.
|
||||||
|
# The empty `order_by` here are actually there to *remove*
|
||||||
|
# any default ordering defined in managers or model Meta
|
||||||
|
groups = user_obj.groups.order_by()
|
||||||
|
if user_obj.is_subscribed:
|
||||||
|
groups = groups.union(
|
||||||
|
Group.objects.filter(pk=settings.SITH_GROUP_SUBSCRIBERS_ID).order_by()
|
||||||
|
)
|
||||||
|
return Permission.objects.filter(
|
||||||
|
group__group__in=groups.values_list("pk", flat=True)
|
||||||
|
)
|
@ -7,7 +7,7 @@ from model_bakery import seq
|
|||||||
from model_bakery.recipe import Recipe, related
|
from model_bakery.recipe import Recipe, related
|
||||||
|
|
||||||
from club.models import Membership
|
from club.models import Membership
|
||||||
from core.models import User
|
from core.models import Group, User
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
active_subscription = Recipe(
|
active_subscription = Recipe(
|
||||||
@ -60,5 +60,6 @@ board_user = Recipe(
|
|||||||
first_name="AE",
|
first_name="AE",
|
||||||
last_name=seq("member "),
|
last_name=seq("member "),
|
||||||
memberships=related(ae_board_membership),
|
memberships=related(ae_board_membership),
|
||||||
|
groups=lambda: [Group.objects.get(club_board=settings.SITH_MAIN_CLUB_ID)],
|
||||||
)
|
)
|
||||||
"""A user which is in the board of the AE."""
|
"""A user which is in the board of the AE."""
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar
|
from typing import ClassVar, NamedTuple
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@ -31,6 +31,7 @@ from django.contrib.sites.models import Site
|
|||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@ -45,8 +46,9 @@ from accounting.models import (
|
|||||||
SimplifiedAccountingType,
|
SimplifiedAccountingType,
|
||||||
)
|
)
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
|
from com.calendar import IcsCalendar
|
||||||
from com.models import News, NewsDate, Sith, Weekmail
|
from com.models import News, NewsDate, Sith, Weekmail
|
||||||
from core.models import Group, Page, PageRev, RealGroup, SithFile, User
|
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
from counter.models import Counter, Product, ProductType, StudentCard
|
from counter.models import Counter, Product, ProductType, StudentCard
|
||||||
from election.models import Candidature, Election, ElectionList, Role
|
from election.models import Candidature, Election, ElectionList, Role
|
||||||
@ -56,6 +58,18 @@ from sas.models import Album, PeoplePictureRelation, Picture
|
|||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
|
class PopulatedGroups(NamedTuple):
|
||||||
|
root: Group
|
||||||
|
public: Group
|
||||||
|
subscribers: Group
|
||||||
|
old_subscribers: Group
|
||||||
|
sas_admin: Group
|
||||||
|
com_admin: Group
|
||||||
|
counter_admin: Group
|
||||||
|
accounting_admin: Group
|
||||||
|
pedagogy_admin: Group
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||||
@ -79,25 +93,8 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
|
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
|
||||||
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
|
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
|
||||||
|
groups = self._create_groups()
|
||||||
root_group = Group.objects.create(name="Root")
|
self._create_ban_groups()
|
||||||
public_group = Group.objects.create(name="Public")
|
|
||||||
subscribers = Group.objects.create(name="Subscribers")
|
|
||||||
old_subscribers = Group.objects.create(name="Old subscribers")
|
|
||||||
Group.objects.create(name="Accounting admin")
|
|
||||||
Group.objects.create(name="Communication admin")
|
|
||||||
Group.objects.create(name="Counter admin")
|
|
||||||
Group.objects.create(name="Banned from buying alcohol")
|
|
||||||
Group.objects.create(name="Banned from counters")
|
|
||||||
Group.objects.create(name="Banned to subscribe")
|
|
||||||
Group.objects.create(name="SAS admin")
|
|
||||||
Group.objects.create(name="Forum admin")
|
|
||||||
Group.objects.create(name="Pedagogy admin")
|
|
||||||
self.reset_index("core", "auth")
|
|
||||||
|
|
||||||
change_billing = Permission.objects.get(codename="change_billinginfo")
|
|
||||||
add_billing = Permission.objects.get(codename="add_billinginfo")
|
|
||||||
root_group.permissions.add(change_billing, add_billing)
|
|
||||||
|
|
||||||
root = User.objects.create_superuser(
|
root = User.objects.create_superuser(
|
||||||
id=0,
|
id=0,
|
||||||
@ -148,14 +145,16 @@ class Command(BaseCommand):
|
|||||||
Counter.objects.bulk_create(counters)
|
Counter.objects.bulk_create(counters)
|
||||||
bar_groups = []
|
bar_groups = []
|
||||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||||
group = RealGroup.objects.create(name=f"{bar_name} admin")
|
group = Group.objects.create(
|
||||||
|
name=f"{bar_name} admin", is_manually_manageable=True
|
||||||
|
)
|
||||||
bar_groups.append(
|
bar_groups.append(
|
||||||
Counter.edit_groups.through(counter_id=bar_id, group=group)
|
Counter.edit_groups.through(counter_id=bar_id, group=group)
|
||||||
)
|
)
|
||||||
Counter.edit_groups.through.objects.bulk_create(bar_groups)
|
Counter.edit_groups.through.objects.bulk_create(bar_groups)
|
||||||
self.reset_index("counter")
|
self.reset_index("counter")
|
||||||
|
|
||||||
subscribers.viewable_files.add(home_root, club_root)
|
groups.subscribers.viewable_files.add(home_root, club_root)
|
||||||
|
|
||||||
Weekmail().save()
|
Weekmail().save()
|
||||||
|
|
||||||
@ -260,21 +259,11 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
User.groups.through.objects.bulk_create(
|
User.groups.through.objects.bulk_create(
|
||||||
[
|
[
|
||||||
User.groups.through(
|
User.groups.through(group=groups.counter_admin, user=counter),
|
||||||
realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
|
User.groups.through(group=groups.accounting_admin, user=comptable),
|
||||||
),
|
User.groups.through(group=groups.com_admin, user=comunity),
|
||||||
User.groups.through(
|
User.groups.through(group=groups.pedagogy_admin, user=tutu),
|
||||||
realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
|
User.groups.through(group=groups.sas_admin, user=skia),
|
||||||
),
|
|
||||||
User.groups.through(
|
|
||||||
realgroup_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
|
|
||||||
),
|
|
||||||
User.groups.through(
|
|
||||||
realgroup_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
|
|
||||||
),
|
|
||||||
User.groups.through(
|
|
||||||
realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
for user in richard, sli, krophil, skia:
|
for user in richard, sli, krophil, skia:
|
||||||
@ -335,7 +324,7 @@ Welcome to the wiki page!
|
|||||||
content="Fonctionnement de la laverie",
|
content="Fonctionnement de la laverie",
|
||||||
)
|
)
|
||||||
|
|
||||||
public_group.viewable_page.set(
|
groups.public.viewable_page.set(
|
||||||
[syntax_page, services_page, index_page, laundry_page]
|
[syntax_page, services_page, index_page, laundry_page]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -381,46 +370,42 @@ Welcome to the wiki page!
|
|||||||
parent=main_club,
|
parent=main_club,
|
||||||
)
|
)
|
||||||
|
|
||||||
Membership.objects.bulk_create(
|
Membership.objects.create(user=skia, club=main_club, role=3)
|
||||||
[
|
Membership.objects.create(
|
||||||
Membership(user=skia, club=main_club, role=3),
|
user=comunity,
|
||||||
Membership(
|
club=bar_club,
|
||||||
user=comunity,
|
start_date=localdate(),
|
||||||
club=bar_club,
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
start_date=localdate(),
|
)
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
Membership.objects.create(
|
||||||
),
|
user=sli,
|
||||||
Membership(
|
club=troll,
|
||||||
user=sli,
|
role=9,
|
||||||
club=troll,
|
description="Padawan Troll",
|
||||||
role=9,
|
start_date=localdate() - timedelta(days=17),
|
||||||
description="Padawan Troll",
|
)
|
||||||
start_date=localdate() - timedelta(days=17),
|
Membership.objects.create(
|
||||||
),
|
user=krophil,
|
||||||
Membership(
|
club=troll,
|
||||||
user=krophil,
|
role=10,
|
||||||
club=troll,
|
description="Maitre Troll",
|
||||||
role=10,
|
start_date=localdate() - timedelta(days=200),
|
||||||
description="Maitre Troll",
|
)
|
||||||
start_date=localdate() - timedelta(days=200),
|
Membership.objects.create(
|
||||||
),
|
user=skia,
|
||||||
Membership(
|
club=troll,
|
||||||
user=skia,
|
role=2,
|
||||||
club=troll,
|
description="Grand Ancien Troll",
|
||||||
role=2,
|
start_date=localdate() - timedelta(days=400),
|
||||||
description="Grand Ancien Troll",
|
end_date=localdate() - timedelta(days=86),
|
||||||
start_date=localdate() - timedelta(days=400),
|
)
|
||||||
end_date=localdate() - timedelta(days=86),
|
Membership.objects.create(
|
||||||
),
|
user=richard,
|
||||||
Membership(
|
club=troll,
|
||||||
user=richard,
|
role=2,
|
||||||
club=troll,
|
description="",
|
||||||
role=2,
|
start_date=localdate() - timedelta(days=200),
|
||||||
description="",
|
end_date=localdate() - timedelta(days=100),
|
||||||
start_date=localdate() - timedelta(days=200),
|
|
||||||
end_date=localdate() - timedelta(days=100),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
p = ProductType.objects.create(name="Bières bouteilles")
|
p = ProductType.objects.create(name="Bières bouteilles")
|
||||||
@ -475,6 +460,7 @@ Welcome to the wiki page!
|
|||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
cons = Product.objects.create(
|
cons = Product.objects.create(
|
||||||
|
id=settings.SITH_ECOCUP_CONS,
|
||||||
name="Consigne Eco-cup",
|
name="Consigne Eco-cup",
|
||||||
code="CONS",
|
code="CONS",
|
||||||
product_type=verre,
|
product_type=verre,
|
||||||
@ -484,6 +470,7 @@ Welcome to the wiki page!
|
|||||||
club=main_club,
|
club=main_club,
|
||||||
)
|
)
|
||||||
dcons = Product.objects.create(
|
dcons = Product.objects.create(
|
||||||
|
id=settings.SITH_ECOCUP_DECO,
|
||||||
name="Déconsigne Eco-cup",
|
name="Déconsigne Eco-cup",
|
||||||
code="DECO",
|
code="DECO",
|
||||||
product_type=verre,
|
product_type=verre,
|
||||||
@ -512,8 +499,10 @@ Welcome to the wiki page!
|
|||||||
club=main_club,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
|
groups.subscribers.products.add(
|
||||||
old_subscribers.products.add(cotis, cotis2)
|
cotis, cotis2, refill, barb, cble, cors, carolus
|
||||||
|
)
|
||||||
|
groups.old_subscribers.products.add(cotis, cotis2)
|
||||||
|
|
||||||
mde = Counter.objects.get(name="MDE")
|
mde = Counter.objects.get(name="MDE")
|
||||||
mde.products.add(barb, cble, cons, dcons)
|
mde.products.add(barb, cble, cons, dcons)
|
||||||
@ -607,7 +596,6 @@ Welcome to the wiki page!
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create an election
|
# Create an election
|
||||||
ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
|
|
||||||
el = Election.objects.create(
|
el = Election.objects.create(
|
||||||
title="Élection 2017",
|
title="Élection 2017",
|
||||||
description="La roue tourne",
|
description="La roue tourne",
|
||||||
@ -616,10 +604,10 @@ Welcome to the wiki page!
|
|||||||
start_date="1942-06-12 10:28:45+01",
|
start_date="1942-06-12 10:28:45+01",
|
||||||
end_date="7942-06-12 10:28:45+01",
|
end_date="7942-06-12 10:28:45+01",
|
||||||
)
|
)
|
||||||
el.view_groups.add(public_group)
|
el.view_groups.add(groups.public)
|
||||||
el.edit_groups.add(ae_board_group)
|
el.edit_groups.add(main_club.board_group)
|
||||||
el.candidature_groups.add(subscribers)
|
el.candidature_groups.add(groups.subscribers)
|
||||||
el.vote_groups.add(subscribers)
|
el.vote_groups.add(groups.subscribers)
|
||||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||||
listeT = ElectionList.objects.create(title="Troll", election=el)
|
listeT = ElectionList.objects.create(title="Troll", election=el)
|
||||||
pres = Role.objects.create(
|
pres = Role.objects.create(
|
||||||
@ -754,7 +742,7 @@ Welcome to the wiki page!
|
|||||||
NewsDate(
|
NewsDate(
|
||||||
news=n,
|
news=n,
|
||||||
start_date=friday + timedelta(hours=24 * 7 + 1),
|
start_date=friday + timedelta(hours=24 * 7 + 1),
|
||||||
end_date=self.now + timedelta(hours=24 * 7 + 9),
|
end_date=friday + timedelta(hours=24 * 7 + 9),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Weekly
|
# Weekly
|
||||||
@ -780,8 +768,9 @@ Welcome to the wiki page!
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
NewsDate.objects.bulk_create(news_dates)
|
NewsDate.objects.bulk_create(news_dates)
|
||||||
|
IcsCalendar.make_internal() # Force refresh of the calendar after a bulk_create
|
||||||
|
|
||||||
# Create som data for pedagogy
|
# Create some data for pedagogy
|
||||||
|
|
||||||
UV(
|
UV(
|
||||||
code="PA00",
|
code="PA00",
|
||||||
@ -898,3 +887,114 @@ Welcome to the wiki page!
|
|||||||
start=s.subscription_start,
|
start=s.subscription_start,
|
||||||
)
|
)
|
||||||
s.save()
|
s.save()
|
||||||
|
|
||||||
|
def _create_groups(self) -> PopulatedGroups:
|
||||||
|
perms = Permission.objects.all()
|
||||||
|
|
||||||
|
root_group = Group.objects.create(name="Root", is_manually_manageable=True)
|
||||||
|
root_group.permissions.add(*list(perms.values_list("pk", flat=True)))
|
||||||
|
# public has no permission.
|
||||||
|
# Its purpose is not to link users to permissions,
|
||||||
|
# but to other objects (like products)
|
||||||
|
public_group = Group.objects.create(name="Public")
|
||||||
|
|
||||||
|
subscribers = Group.objects.create(name="Subscribers")
|
||||||
|
old_subscribers = Group.objects.create(name="Old subscribers")
|
||||||
|
old_subscribers.permissions.add(
|
||||||
|
*list(
|
||||||
|
perms.filter(
|
||||||
|
codename__in=[
|
||||||
|
"view_user",
|
||||||
|
"view_picture",
|
||||||
|
"view_album",
|
||||||
|
"view_peoplepicturerelation",
|
||||||
|
"add_peoplepicturerelation",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
accounting_admin = Group.objects.create(
|
||||||
|
name="Accounting admin", is_manually_manageable=True
|
||||||
|
)
|
||||||
|
accounting_admin.permissions.add(
|
||||||
|
*list(
|
||||||
|
perms.filter(
|
||||||
|
Q(content_type__app_label="accounting")
|
||||||
|
| Q(
|
||||||
|
codename__in=[
|
||||||
|
"view_customer",
|
||||||
|
"view_product",
|
||||||
|
"change_product",
|
||||||
|
"add_product",
|
||||||
|
"view_producttype",
|
||||||
|
"change_producttype",
|
||||||
|
"add_producttype",
|
||||||
|
"delete_selling",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
).values_list("pk", flat=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
com_admin = Group.objects.create(
|
||||||
|
name="Communication admin", is_manually_manageable=True
|
||||||
|
)
|
||||||
|
com_admin.permissions.add(
|
||||||
|
*list(
|
||||||
|
perms.filter(content_type__app_label="com").values_list("pk", flat=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counter_admin = Group.objects.create(
|
||||||
|
name="Counter admin", is_manually_manageable=True
|
||||||
|
)
|
||||||
|
counter_admin.permissions.add(
|
||||||
|
*list(
|
||||||
|
perms.filter(
|
||||||
|
Q(content_type__app_label__in=["counter", "launderette"])
|
||||||
|
& ~Q(codename__in=["delete_product", "delete_producttype"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
|
||||||
|
sas_admin.permissions.add(
|
||||||
|
*list(
|
||||||
|
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
forum_admin = Group.objects.create(
|
||||||
|
name="Forum admin", is_manually_manageable=True
|
||||||
|
)
|
||||||
|
forum_admin.permissions.add(
|
||||||
|
*list(
|
||||||
|
perms.filter(content_type__app_label="forum").values_list(
|
||||||
|
"pk", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pedagogy_admin = Group.objects.create(
|
||||||
|
name="Pedagogy admin", is_manually_manageable=True
|
||||||
|
)
|
||||||
|
pedagogy_admin.permissions.add(
|
||||||
|
*list(
|
||||||
|
perms.filter(content_type__app_label="pedagogy").values_list(
|
||||||
|
"pk", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.reset_index("core", "auth")
|
||||||
|
|
||||||
|
return PopulatedGroups(
|
||||||
|
root=root_group,
|
||||||
|
public=public_group,
|
||||||
|
subscribers=subscribers,
|
||||||
|
old_subscribers=old_subscribers,
|
||||||
|
com_admin=com_admin,
|
||||||
|
counter_admin=counter_admin,
|
||||||
|
accounting_admin=accounting_admin,
|
||||||
|
sas_admin=sas_admin,
|
||||||
|
pedagogy_admin=pedagogy_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_ban_groups(self):
|
||||||
|
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
||||||
|
BanGroup.objects.create(name="Banned from counters", description="")
|
||||||
|
BanGroup.objects.create(name="Banned to subscribe", description="")
|
||||||
|
@ -11,7 +11,7 @@ from django.utils.timezone import localdate, make_aware, now
|
|||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from core.models import RealGroup, User
|
from core.models import Group, User
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
@ -173,7 +173,8 @@ class Command(BaseCommand):
|
|||||||
club=club,
|
club=club,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Membership.objects.bulk_create(memberships)
|
memberships = Membership.objects.bulk_create(memberships)
|
||||||
|
Membership._add_club_groups(memberships)
|
||||||
|
|
||||||
def create_uvs(self):
|
def create_uvs(self):
|
||||||
root = User.objects.get(username="root")
|
root = User.objects.get(username="root")
|
||||||
@ -225,9 +226,7 @@ class Command(BaseCommand):
|
|||||||
ae = Club.objects.get(unix_name="ae")
|
ae = Club.objects.get(unix_name="ae")
|
||||||
other_clubs = random.sample(list(Club.objects.all()), k=3)
|
other_clubs = random.sample(list(Club.objects.all()), k=3)
|
||||||
groups = list(
|
groups = list(
|
||||||
RealGroup.objects.filter(
|
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
|
||||||
name__in=["Subscribers", "Old subscribers", "Public"]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
counters = list(
|
counters = list(
|
||||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
|
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -29,7 +30,7 @@ class Command(BaseCommand):
|
|||||||
if not data_dir.is_dir():
|
if not data_dir.is_dir():
|
||||||
data_dir.mkdir()
|
data_dir.mkdir()
|
||||||
db_path = settings.BASE_DIR / "db.sqlite3"
|
db_path = settings.BASE_DIR / "db.sqlite3"
|
||||||
if db_path.exists():
|
if db_path.exists() or connection.vendor != "sqlite":
|
||||||
call_command("flush", "--noinput")
|
call_command("flush", "--noinput")
|
||||||
self.stdout.write("Existing database reset")
|
self.stdout.write("Existing database reset")
|
||||||
call_command("migrate")
|
call_command("migrate")
|
||||||
|
@ -563,14 +563,21 @@ class Migration(migrations.Migration):
|
|||||||
fields=[],
|
fields=[],
|
||||||
options={"proxy": True},
|
options={"proxy": True},
|
||||||
bases=("core.group",),
|
bases=("core.group",),
|
||||||
managers=[("objects", core.models.MetaGroupManager())],
|
managers=[("objects", django.contrib.auth.models.GroupManager())],
|
||||||
),
|
),
|
||||||
|
# at first, there existed a RealGroupManager and a RealGroupManager,
|
||||||
|
# which have been since been removed.
|
||||||
|
# However, this removal broke the migrations because it caused an ImportError.
|
||||||
|
# Thus, the managers MetaGroupManager (above) and RealGroupManager (below)
|
||||||
|
# have been replaced by the base django GroupManager to fix the import.
|
||||||
|
# As those managers aren't actually used in migrations,
|
||||||
|
# this replacement doesn't break anything.
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="RealGroup",
|
name="RealGroup",
|
||||||
fields=[],
|
fields=[],
|
||||||
options={"proxy": True},
|
options={"proxy": True},
|
||||||
bases=("core.group",),
|
bases=("core.group",),
|
||||||
managers=[("objects", core.models.RealGroupManager())],
|
managers=[("objects", django.contrib.auth.models.GroupManager())],
|
||||||
),
|
),
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name="page", unique_together={("name", "parent")}
|
name="page", unique_together={("name", "parent")}
|
||||||
|
@ -0,0 +1,82 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-11-20 16:22
|
||||||
|
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("core", "0039_alter_user_managers"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="user",
|
||||||
|
options={"verbose_name": "user", "verbose_name_plural": "users"},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="user_permissions",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="user permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="date_joined",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now, verbose_name="date joined"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="is_superuser",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
|
verbose_name="superuser status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="username",
|
||||||
|
field=models.CharField(
|
||||||
|
error_messages={"unique": "A user with that username already exists."},
|
||||||
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
|
max_length=150,
|
||||||
|
unique=True,
|
||||||
|
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
|
||||||
|
verbose_name="username",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="groups",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="groups",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="users",
|
||||||
|
to="core.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-11-30 13:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0040_alter_user_options_user_user_permissions_and_more"),
|
||||||
|
("club", "0013_alter_club_board_group_alter_club_members_group_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="MetaGroup",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="RealGroup",
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="group",
|
||||||
|
options={},
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="group",
|
||||||
|
old_name="is_meta",
|
||||||
|
new_name="is_manually_manageable",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="group",
|
||||||
|
name="is_manually_manageable",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="If False, this shouldn't be shown on group management pages",
|
||||||
|
verbose_name="Is manually manageable",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2025-01-04 16:42
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import F
|
||||||
|
|
||||||
|
|
||||||
|
def invert_is_manually_manageable(apps: StateApps, schema_editor):
|
||||||
|
"""Invert `is_manually_manageable`.
|
||||||
|
|
||||||
|
This field is a renaming of `is_meta`.
|
||||||
|
However, the meaning has been inverted : the groups
|
||||||
|
which were meta are not manually manageable and vice versa.
|
||||||
|
Thus, the value must be inverted.
|
||||||
|
"""
|
||||||
|
Group = apps.get_model("core", "Group")
|
||||||
|
Group.objects.all().update(is_manually_manageable=~F("is_manually_manageable"))
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("core", "0041_delete_metagroup_alter_group_options_and_more")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
invert_is_manually_manageable, reverse_code=invert_is_manually_manageable
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,164 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2024-12-31 13:30
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_ban_groups(apps: StateApps, schema_editor):
|
||||||
|
Group = apps.get_model("core", "Group")
|
||||||
|
BanGroup = apps.get_model("core", "BanGroup")
|
||||||
|
ban_group_ids = [
|
||||||
|
settings.SITH_GROUP_BANNED_ALCOHOL_ID,
|
||||||
|
settings.SITH_GROUP_BANNED_COUNTER_ID,
|
||||||
|
settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
|
||||||
|
]
|
||||||
|
# this is a N+1 Queries, but the prod database has a grand total of 3 ban groups
|
||||||
|
for group in Group.objects.filter(id__in=ban_group_ids):
|
||||||
|
# auth_group, which both Group and BanGroup inherit,
|
||||||
|
# is unique by name.
|
||||||
|
# If we tried give the exact same name to the migrated BanGroup
|
||||||
|
# before deleting the corresponding Group,
|
||||||
|
# we would have an IntegrityError.
|
||||||
|
# So we append a space to the name, in order to create a name
|
||||||
|
# that will look the same, but that isn't really the same.
|
||||||
|
ban_group = BanGroup.objects.create(
|
||||||
|
name=f"{group.name} ",
|
||||||
|
description=group.description,
|
||||||
|
)
|
||||||
|
perms = list(group.permissions.values_list("id", flat=True))
|
||||||
|
if perms:
|
||||||
|
ban_group.permissions.add(*perms)
|
||||||
|
ban_group.users.add(
|
||||||
|
*group.users.values_list("id", flat=True), through_defaults={"reason": ""}
|
||||||
|
)
|
||||||
|
group.delete()
|
||||||
|
# now that the original group is no longer there,
|
||||||
|
# we can remove the appended space
|
||||||
|
ban_group.name = ban_group.name.strip()
|
||||||
|
ban_group.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("core", "0042_invert_is_manually_manageable_20250104_1742"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BanGroup",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"group_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="auth.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", models.TextField(verbose_name="description")),
|
||||||
|
],
|
||||||
|
bases=("auth.group",),
|
||||||
|
managers=[
|
||||||
|
("objects", django.contrib.auth.models.GroupManager()),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "ban group",
|
||||||
|
"verbose_name_plural": "ban groups",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="group",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(verbose_name="description"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="groups",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="users",
|
||||||
|
to="core.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserBan",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"expires_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="When the ban should be removed. Currently, there is no automatic removal, so this is purely indicative. Automatic ban removal may be implemented later on.",
|
||||||
|
null=True,
|
||||||
|
verbose_name="expires at",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("reason", models.TextField(verbose_name="reason")),
|
||||||
|
(
|
||||||
|
"ban_group",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="user_bans",
|
||||||
|
to="core.bangroup",
|
||||||
|
verbose_name="ban type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="bans",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="user",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="ban_groups",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
help_text="The bans this user has received.",
|
||||||
|
related_name="users",
|
||||||
|
through="core.UserBan",
|
||||||
|
to="core.bangroup",
|
||||||
|
verbose_name="ban groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="userban",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("ban_group", "user"), name="unique_ban_type_per_user"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="userban",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
check=models.Q(("expires_at__gte", models.F("created_at"))),
|
||||||
|
name="user_ban_end_after_start",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_ban_groups, reverse_code=migrations.RunPython.noop, elidable=True
|
||||||
|
),
|
||||||
|
]
|
310
core/models.py
310
core/models.py
@ -30,26 +30,19 @@ import string
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Optional, Self
|
from typing import TYPE_CHECKING, Optional, Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractBaseUser, UserManager
|
from django.contrib.auth.models import AbstractUser, UserManager
|
||||||
from django.contrib.auth.models import (
|
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
|
||||||
AnonymousUser as AuthAnonymousUser,
|
from django.contrib.auth.models import Group as AuthGroup
|
||||||
)
|
|
||||||
from django.contrib.auth.models import (
|
|
||||||
Group as AuthGroup,
|
|
||||||
)
|
|
||||||
from django.contrib.auth.models import (
|
|
||||||
GroupManager as AuthGroupManager,
|
|
||||||
)
|
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
from django.core import validators
|
from django.core import validators
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, F, OuterRef, Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@ -64,33 +57,15 @@ if TYPE_CHECKING:
|
|||||||
from club.models import Club
|
from club.models import Club
|
||||||
|
|
||||||
|
|
||||||
class RealGroupManager(AuthGroupManager):
|
|
||||||
def get_queryset(self):
|
|
||||||
return super().get_queryset().filter(is_meta=False)
|
|
||||||
|
|
||||||
|
|
||||||
class MetaGroupManager(AuthGroupManager):
|
|
||||||
def get_queryset(self):
|
|
||||||
return super().get_queryset().filter(is_meta=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Group(AuthGroup):
|
class Group(AuthGroup):
|
||||||
"""Implement both RealGroups and Meta groups.
|
"""Wrapper around django.auth.Group"""
|
||||||
|
|
||||||
Groups are sorted by their is_meta property
|
is_manually_manageable = models.BooleanField(
|
||||||
"""
|
_("Is manually manageable"),
|
||||||
|
|
||||||
#: If False, this is a RealGroup
|
|
||||||
is_meta = models.BooleanField(
|
|
||||||
_("meta group status"),
|
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_("Whether a group is a meta group or not"),
|
help_text=_("If False, this shouldn't be shown on group management pages"),
|
||||||
)
|
)
|
||||||
#: Description of the group
|
description = models.TextField(_("description"))
|
||||||
description = models.CharField(_("description"), max_length=60)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
return reverse("core:group_list")
|
return reverse("core:group_list")
|
||||||
@ -106,65 +81,6 @@ class Group(AuthGroup):
|
|||||||
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
|
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
|
||||||
|
|
||||||
|
|
||||||
class MetaGroup(Group):
|
|
||||||
"""MetaGroups are dynamically created groups.
|
|
||||||
|
|
||||||
Generally used with clubs where creating a club creates two groups:
|
|
||||||
|
|
||||||
* club-SITH_BOARD_SUFFIX
|
|
||||||
* club-SITH_MEMBER_SUFFIX
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=False
|
|
||||||
objects = MetaGroupManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
proxy = True
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.is_meta = True
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def associated_club(self) -> Club | None:
|
|
||||||
"""Return the group associated with this meta group.
|
|
||||||
|
|
||||||
The result of this function is cached
|
|
||||||
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The associated club if it exists, else None
|
|
||||||
"""
|
|
||||||
from club.models import Club
|
|
||||||
|
|
||||||
if self.name.endswith(settings.SITH_BOARD_SUFFIX):
|
|
||||||
# replace this with str.removesuffix as soon as Python
|
|
||||||
# is upgraded to 3.10
|
|
||||||
club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)]
|
|
||||||
elif self.name.endswith(settings.SITH_MEMBER_SUFFIX):
|
|
||||||
club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
club = cache.get(f"sith_club_{club_name}")
|
|
||||||
if club is None:
|
|
||||||
club = Club.objects.filter(unix_name=club_name).first()
|
|
||||||
cache.set(f"sith_club_{club_name}", club)
|
|
||||||
return club
|
|
||||||
|
|
||||||
|
|
||||||
class RealGroup(Group):
|
|
||||||
"""RealGroups are created by the developer.
|
|
||||||
|
|
||||||
Most of the time they match a number in settings to be easily used for permissions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=True
|
|
||||||
objects = RealGroupManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
proxy = True
|
|
||||||
|
|
||||||
|
|
||||||
def validate_promo(value: int) -> None:
|
def validate_promo(value: int) -> None:
|
||||||
start_year = settings.SITH_SCHOOL_START_YEAR
|
start_year = settings.SITH_SCHOOL_START_YEAR
|
||||||
delta = (localdate() + timedelta(days=180)).year - start_year
|
delta = (localdate() + timedelta(days=180)).year - start_year
|
||||||
@ -210,13 +126,35 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
|
|||||||
else:
|
else:
|
||||||
group = Group.objects.filter(name=name).first()
|
group = Group.objects.filter(name=name).first()
|
||||||
if group is not None:
|
if group is not None:
|
||||||
cache.set(f"sith_group_{group.id}", group)
|
name = group.name.replace(" ", "_")
|
||||||
cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
|
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
|
||||||
else:
|
else:
|
||||||
cache.set(f"sith_group_{pk_or_name}", "not_found")
|
cache.set(f"sith_group_{pk_or_name}", "not_found")
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
class BanGroup(AuthGroup):
|
||||||
|
"""An anti-group, that removes permissions instead of giving them.
|
||||||
|
|
||||||
|
Users are linked to BanGroups through UserBan objects.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
user = User.objects.get(username="...")
|
||||||
|
ban_group = BanGroup.objects.first()
|
||||||
|
UserBan.objects.create(user=user, ban_group=ban_group, reason="...")
|
||||||
|
|
||||||
|
assert user.ban_groups.contains(ban_group)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
description = models.TextField(_("description"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("ban group")
|
||||||
|
verbose_name_plural = _("ban groups")
|
||||||
|
|
||||||
|
|
||||||
class UserQuerySet(models.QuerySet):
|
class UserQuerySet(models.QuerySet):
|
||||||
def filter_inactive(self) -> Self:
|
def filter_inactive(self) -> Self:
|
||||||
from counter.models import Refilling, Selling
|
from counter.models import Refilling, Selling
|
||||||
@ -242,7 +180,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser):
|
class User(AbstractUser):
|
||||||
"""Defines the base user class, useable in every app.
|
"""Defines the base user class, useable in every app.
|
||||||
|
|
||||||
This is almost the same as the auth module AbstractUser since it inherits from it,
|
This is almost the same as the auth module AbstractUser since it inherits from it,
|
||||||
@ -253,51 +191,28 @@ class User(AbstractBaseUser):
|
|||||||
Required fields: email, first_name, last_name, date_of_birth
|
Required fields: email, first_name, last_name, date_of_birth
|
||||||
"""
|
"""
|
||||||
|
|
||||||
username = models.CharField(
|
|
||||||
_("username"),
|
|
||||||
max_length=254,
|
|
||||||
unique=True,
|
|
||||||
help_text=_(
|
|
||||||
"Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
|
|
||||||
),
|
|
||||||
validators=[
|
|
||||||
validators.RegexValidator(
|
|
||||||
r"^[\w.+-]+$",
|
|
||||||
_(
|
|
||||||
"Enter a valid username. This value may contain only "
|
|
||||||
"letters, numbers "
|
|
||||||
"and ./+/-/_ characters."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
error_messages={"unique": _("A user with that username already exists.")},
|
|
||||||
)
|
|
||||||
first_name = models.CharField(_("first name"), max_length=64)
|
first_name = models.CharField(_("first name"), max_length=64)
|
||||||
last_name = models.CharField(_("last name"), max_length=64)
|
last_name = models.CharField(_("last name"), max_length=64)
|
||||||
email = models.EmailField(_("email address"), unique=True)
|
email = models.EmailField(_("email address"), unique=True)
|
||||||
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
|
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
|
||||||
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
|
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
|
||||||
is_staff = models.BooleanField(
|
|
||||||
_("staff status"),
|
|
||||||
default=False,
|
|
||||||
help_text=_("Designates whether the user can log into this admin site."),
|
|
||||||
)
|
|
||||||
is_active = models.BooleanField(
|
|
||||||
_("active"),
|
|
||||||
default=True,
|
|
||||||
help_text=_(
|
|
||||||
"Designates whether this user should be treated as active. "
|
|
||||||
"Unselect this instead of deleting accounts."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
date_joined = models.DateField(_("date joined"), auto_now_add=True)
|
|
||||||
last_update = models.DateTimeField(_("last update"), auto_now=True)
|
last_update = models.DateTimeField(_("last update"), auto_now=True)
|
||||||
is_superuser = models.BooleanField(
|
groups = models.ManyToManyField(
|
||||||
_("superuser"),
|
Group,
|
||||||
default=False,
|
verbose_name=_("groups"),
|
||||||
help_text=_("Designates whether this user is a superuser. "),
|
help_text=_(
|
||||||
|
"The groups this user belongs to. A user will get all permissions "
|
||||||
|
"granted to each of their groups."
|
||||||
|
),
|
||||||
|
related_name="users",
|
||||||
|
)
|
||||||
|
ban_groups = models.ManyToManyField(
|
||||||
|
BanGroup,
|
||||||
|
verbose_name=_("ban groups"),
|
||||||
|
through="UserBan",
|
||||||
|
help_text=_("The bans this user has received."),
|
||||||
|
related_name="users",
|
||||||
)
|
)
|
||||||
groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
|
|
||||||
home = models.OneToOneField(
|
home = models.OneToOneField(
|
||||||
"SithFile",
|
"SithFile",
|
||||||
related_name="home_of",
|
related_name="home_of",
|
||||||
@ -401,8 +316,6 @@ class User(AbstractBaseUser):
|
|||||||
|
|
||||||
objects = CustomUserManager()
|
objects = CustomUserManager()
|
||||||
|
|
||||||
USERNAME_FIELD = "username"
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.get_display_name()
|
return self.get_display_name()
|
||||||
|
|
||||||
@ -422,22 +335,23 @@ class User(AbstractBaseUser):
|
|||||||
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
|
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
def has_module_perms(self, package_name: str) -> bool:
|
|
||||||
return self.is_active
|
|
||||||
|
|
||||||
def has_perm(self, perm: str, obj: Any = None) -> bool:
|
|
||||||
return self.is_active and self.is_superuser
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def was_subscribed(self) -> bool:
|
def was_subscribed(self) -> bool:
|
||||||
|
if "is_subscribed" in self.__dict__ and self.is_subscribed:
|
||||||
|
# if the user is currently subscribed, he is an old subscriber too
|
||||||
|
# if the property has already been cached, avoid another request
|
||||||
|
return True
|
||||||
return self.subscriptions.exists()
|
return self.subscriptions.exists()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_subscribed(self) -> bool:
|
def is_subscribed(self) -> bool:
|
||||||
s = self.subscriptions.filter(
|
if "was_subscribed" in self.__dict__ and not self.was_subscribed:
|
||||||
|
# if the user never subscribed, he cannot be a subscriber now.
|
||||||
|
# if the property has already been cached, avoid another request
|
||||||
|
return False
|
||||||
|
return self.subscriptions.filter(
|
||||||
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
|
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
|
||||||
)
|
).exists()
|
||||||
return s.exists()
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def account_balance(self):
|
def account_balance(self):
|
||||||
@ -474,18 +388,6 @@ class User(AbstractBaseUser):
|
|||||||
return self.was_subscribed
|
return self.was_subscribed
|
||||||
if group.id == settings.SITH_GROUP_ROOT_ID:
|
if group.id == settings.SITH_GROUP_ROOT_ID:
|
||||||
return self.is_root
|
return self.is_root
|
||||||
if group.is_meta:
|
|
||||||
# check if this group is associated with a club
|
|
||||||
group.__class__ = MetaGroup
|
|
||||||
club = group.associated_club
|
|
||||||
if club is None:
|
|
||||||
return False
|
|
||||||
membership = club.get_membership_for(self)
|
|
||||||
if membership is None:
|
|
||||||
return False
|
|
||||||
if group.name.endswith(settings.SITH_MEMBER_SUFFIX):
|
|
||||||
return True
|
|
||||||
return membership.role > settings.SITH_MAXIMUM_FREE_ROLE
|
|
||||||
return group in self.cached_groups
|
return group in self.cached_groups
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -510,12 +412,11 @@ class User(AbstractBaseUser):
|
|||||||
return any(g.id == root_id for g in self.cached_groups)
|
return any(g.id == root_id for g in self.cached_groups)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_board_member(self):
|
def is_board_member(self) -> bool:
|
||||||
main_club = settings.SITH_MAIN_CLUB["unix_name"]
|
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
|
||||||
return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def can_read_subscription_history(self):
|
def can_read_subscription_history(self) -> bool:
|
||||||
if self.is_root or self.is_board_member:
|
if self.is_root or self.is_board_member:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -530,10 +431,8 @@ class User(AbstractBaseUser):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def can_create_subscription(self) -> bool:
|
def can_create_subscription(self) -> bool:
|
||||||
from club.models import Membership
|
return self.is_root or (
|
||||||
|
self.memberships.board()
|
||||||
return (
|
|
||||||
Membership.objects.board()
|
|
||||||
.ongoing()
|
.ongoing()
|
||||||
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
|
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
|
||||||
.exists()
|
.exists()
|
||||||
@ -552,12 +451,12 @@ class User(AbstractBaseUser):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_banned_alcohol(self):
|
def is_banned_alcohol(self) -> bool:
|
||||||
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_banned_counter(self):
|
def is_banned_counter(self) -> bool:
|
||||||
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def age(self) -> int:
|
def age(self) -> int:
|
||||||
@ -601,11 +500,6 @@ class User(AbstractBaseUser):
|
|||||||
"date_of_birth": self.date_of_birth,
|
"date_of_birth": self.date_of_birth,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_full_name(self):
|
|
||||||
"""Returns the first_name plus the last_name, with a space in between."""
|
|
||||||
full_name = "%s %s" % (self.first_name, self.last_name)
|
|
||||||
return full_name.strip()
|
|
||||||
|
|
||||||
def get_short_name(self):
|
def get_short_name(self):
|
||||||
"""Returns the short name for the user."""
|
"""Returns the short name for the user."""
|
||||||
if self.nick_name:
|
if self.nick_name:
|
||||||
@ -621,14 +515,6 @@ class User(AbstractBaseUser):
|
|||||||
return "%s (%s)" % (self.get_full_name(), self.nick_name)
|
return "%s (%s)" % (self.get_full_name(), self.nick_name)
|
||||||
return self.get_full_name()
|
return self.get_full_name()
|
||||||
|
|
||||||
def get_age(self):
|
|
||||||
"""Returns the age."""
|
|
||||||
today = timezone.now()
|
|
||||||
born = self.date_of_birth
|
|
||||||
return (
|
|
||||||
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_family(
|
def get_family(
|
||||||
self,
|
self,
|
||||||
godfathers_depth: NonNegativeInt = 4,
|
godfathers_depth: NonNegativeInt = 4,
|
||||||
@ -872,6 +758,52 @@ class AnonymousUser(AuthAnonymousUser):
|
|||||||
return _("Visitor")
|
return _("Visitor")
|
||||||
|
|
||||||
|
|
||||||
|
class UserBan(models.Model):
|
||||||
|
"""A ban of a user.
|
||||||
|
|
||||||
|
A user can be banned for a specific reason, for a specific duration.
|
||||||
|
The expiration date is indicative, and the ban should be removed manually.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ban_group = models.ForeignKey(
|
||||||
|
BanGroup,
|
||||||
|
verbose_name=_("ban type"),
|
||||||
|
related_name="user_bans",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User, verbose_name=_("user"), related_name="bans", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||||
|
expires_at = models.DateTimeField(
|
||||||
|
_("expires at"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_(
|
||||||
|
"When the ban should be removed. "
|
||||||
|
"Currently, there is no automatic removal, so this is purely indicative. "
|
||||||
|
"Automatic ban removal may be implemented later on."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
reason = models.TextField(_("reason"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("user ban")
|
||||||
|
verbose_name_plural = _("user bans")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["ban_group", "user"], name="unique_ban_type_per_user"
|
||||||
|
),
|
||||||
|
models.CheckConstraint(
|
||||||
|
check=Q(expires_at__gte=F("created_at")),
|
||||||
|
name="user_ban_end_after_start",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Ban of user {self.user.id}"
|
||||||
|
|
||||||
|
|
||||||
class Preferences(models.Model):
|
class Preferences(models.Model):
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
User, related_name="_preferences", on_delete=models.CASCADE
|
User, related_name="_preferences", on_delete=models.CASCADE
|
||||||
@ -984,19 +916,17 @@ class SithFile(models.Model):
|
|||||||
if copy_rights:
|
if copy_rights:
|
||||||
self.copy_rights()
|
self.copy_rights()
|
||||||
if self.is_in_sas:
|
if self.is_in_sas:
|
||||||
for u in (
|
for user in User.objects.filter(
|
||||||
RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
|
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
|
||||||
.first()
|
|
||||||
.users.all()
|
|
||||||
):
|
):
|
||||||
Notification(
|
Notification(
|
||||||
user=u,
|
user=user,
|
||||||
url=reverse("sas:moderation"),
|
url=reverse("sas:moderation"),
|
||||||
type="SAS_MODERATION",
|
type="SAS_MODERATION",
|
||||||
param="1",
|
param="1",
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
def is_owned_by(self, user: User) -> bool:
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
if user.is_root:
|
if user.is_root:
|
||||||
@ -1011,7 +941,7 @@ class SithFile(models.Model):
|
|||||||
return True
|
return True
|
||||||
return user.id == self.owner_id
|
return user.id == self.owner_id
|
||||||
|
|
||||||
def can_be_viewed_by(self, user):
|
def can_be_viewed_by(self, user: User) -> bool:
|
||||||
if hasattr(self, "profile_of"):
|
if hasattr(self, "profile_of"):
|
||||||
return user.can_view(self.profile_of)
|
return user.can_view(self.profile_of)
|
||||||
if hasattr(self, "avatar_of"):
|
if hasattr(self, "avatar_of"):
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import sort from "@alpinejs/sort";
|
||||||
import Alpine from "alpinejs";
|
import Alpine from "alpinejs";
|
||||||
|
|
||||||
|
Alpine.plugin(sort);
|
||||||
window.Alpine = Alpine;
|
window.Alpine = Alpine;
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
remove_button: {
|
remove_button: {
|
||||||
title: gettext("Remove"),
|
title: gettext("Remove"),
|
||||||
},
|
},
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is required by the api
|
||||||
|
restore_on_backspace: {},
|
||||||
},
|
},
|
||||||
persist: false,
|
persist: false,
|
||||||
maxItems: this.node.multiple ? this.max : 1,
|
maxItems: this.node.multiple ? this.max : 1,
|
||||||
|
73
core/static/bundled/core/read-more-index.ts
Normal file
73
core/static/bundled/core/read-more-index.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import clip from "@arendjr/text-clipper";
|
||||||
|
|
||||||
|
/*
|
||||||
|
This script adds a way to have a 'show more / show less' button
|
||||||
|
on some text content.
|
||||||
|
|
||||||
|
The usage is very simple, you just have to add the attribute `show-more`
|
||||||
|
with the desired max size to the element you want to add the button to.
|
||||||
|
This script does html matching and is able to properly cut rendered markdown.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
<p show-more="20">
|
||||||
|
My very long text will be cut by this script
|
||||||
|
</p>
|
||||||
|
*/
|
||||||
|
|
||||||
|
function showMore(element: HTMLElement) {
|
||||||
|
if (!element.hasAttribute("show-more")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark element as loaded so we can hide unloaded
|
||||||
|
// tags with css and avoid blinking text
|
||||||
|
element.setAttribute("show-more-loaded", "");
|
||||||
|
|
||||||
|
const fullContent = element.innerHTML;
|
||||||
|
const clippedContent = clip(
|
||||||
|
element.innerHTML,
|
||||||
|
Number.parseInt(element.getAttribute("show-more") as string),
|
||||||
|
{
|
||||||
|
html: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// If already at the desired size, we don't do anything
|
||||||
|
if (clippedContent === fullContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLink = document.createElement("a");
|
||||||
|
actionLink.setAttribute("class", "show-more-link");
|
||||||
|
|
||||||
|
let opened = false;
|
||||||
|
|
||||||
|
const setText = () => {
|
||||||
|
if (opened) {
|
||||||
|
element.innerHTML = fullContent;
|
||||||
|
actionLink.innerText = gettext("Show less");
|
||||||
|
} else {
|
||||||
|
element.innerHTML = clippedContent;
|
||||||
|
actionLink.innerText = gettext("Show more");
|
||||||
|
}
|
||||||
|
element.appendChild(document.createElement("br"));
|
||||||
|
element.appendChild(actionLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
opened = !opened;
|
||||||
|
setText();
|
||||||
|
};
|
||||||
|
|
||||||
|
setText();
|
||||||
|
actionLink.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
for (const elem of document.querySelectorAll("[show-more]")) {
|
||||||
|
showMore(elem as HTMLElement);
|
||||||
|
}
|
||||||
|
});
|
@ -22,10 +22,13 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
|
|||||||
|
|
||||||
// TODO : If one day a test workflow is made for JS in this project
|
// TODO : If one day a test workflow is made for JS in this project
|
||||||
// please test this function. A all cost.
|
// please test this function. A all cost.
|
||||||
|
/**
|
||||||
|
* Load complete dataset from paginated routes.
|
||||||
|
*/
|
||||||
export const paginated = async <T>(
|
export const paginated = async <T>(
|
||||||
endpoint: PaginatedEndpoint<T>,
|
endpoint: PaginatedEndpoint<T>,
|
||||||
options?: PaginatedRequest,
|
options?: PaginatedRequest,
|
||||||
) => {
|
): Promise<T[]> => {
|
||||||
const maxPerPage = 199;
|
const maxPerPage = 199;
|
||||||
const queryParams = options ?? {};
|
const queryParams = options ?? {};
|
||||||
queryParams.query = queryParams.query ?? {};
|
queryParams.query = queryParams.query ?? {};
|
||||||
|
49
core/static/bundled/utils/csv.ts
Normal file
49
core/static/bundled/utils/csv.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { NestedKeyOf } from "#core:utils/types";
|
||||||
|
|
||||||
|
interface StringifyOptions<T extends object> {
|
||||||
|
/** The columns to include in the resulting CSV. */
|
||||||
|
columns: readonly NestedKeyOf<T>[];
|
||||||
|
/** Content of the first row */
|
||||||
|
titleRow?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
|
||||||
|
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
|
||||||
|
let res = obj[path.shift() as keyof T];
|
||||||
|
for (const node of path) {
|
||||||
|
if (res === null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
res = res[node];
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the content the string to make sure it won't break
|
||||||
|
* the resulting csv.
|
||||||
|
* cf. https://en.wikipedia.org/wiki/Comma-separated_values#Basic_rules
|
||||||
|
*/
|
||||||
|
function sanitizeCell(content: string): string {
|
||||||
|
return `"${content.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const csv = {
|
||||||
|
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
|
||||||
|
const columns = options.columns;
|
||||||
|
const content = objs
|
||||||
|
.map((obj) => {
|
||||||
|
return columns
|
||||||
|
.map((col) => {
|
||||||
|
return sanitizeCell((getNested(obj, col) ?? "").toString());
|
||||||
|
})
|
||||||
|
.join(",");
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
if (!options.titleRow) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
const firstRow = options.titleRow.map(sanitizeCell).join(",");
|
||||||
|
return `${firstRow}\n${content}`;
|
||||||
|
},
|
||||||
|
};
|
37
core/static/bundled/utils/types.d.ts
vendored
Normal file
37
core/static/bundled/utils/types.d.ts
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* A key of an object, or of one of its descendants.
|
||||||
|
*
|
||||||
|
* Example :
|
||||||
|
* ```typescript
|
||||||
|
* interface Foo {
|
||||||
|
* foo_inner: number;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* interface Bar {
|
||||||
|
* foo: Foo;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const foo = (key: NestedKeyOf<Bar>) {
|
||||||
|
* console.log(key);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* foo("foo.foo_inner"); // OK
|
||||||
|
* foo("foo.bar"); // FAIL
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type NestedKeyOf<T extends object> = {
|
||||||
|
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<T[Key], `${Key}`>;
|
||||||
|
}[keyof T & (string | number)];
|
||||||
|
|
||||||
|
type NestedKeyOfInner<T extends object> = {
|
||||||
|
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<
|
||||||
|
T[Key],
|
||||||
|
`['${Key}']` | `.${Key}`
|
||||||
|
>;
|
||||||
|
}[keyof T & (string | number)];
|
||||||
|
|
||||||
|
type NestedKeyOfHandleValue<T, Text extends string> = T extends unknown[]
|
||||||
|
? Text
|
||||||
|
: T extends object
|
||||||
|
? Text | `${Text}${NestedKeyOfInner<T>}`
|
||||||
|
: Text;
|
@ -24,6 +24,9 @@ $black-color: hsl(0, 0%, 17%);
|
|||||||
|
|
||||||
$faceblue: hsl(221, 44%, 41%);
|
$faceblue: hsl(221, 44%, 41%);
|
||||||
$twitblue: hsl(206, 82%, 63%);
|
$twitblue: hsl(206, 82%, 63%);
|
||||||
|
$discordblurple: #7289da;
|
||||||
|
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
|
||||||
|
$githubblack: rgb(22, 22, 20);
|
||||||
|
|
||||||
$shadow-color: rgb(223, 223, 223);
|
$shadow-color: rgb(223, 223, 223);
|
||||||
|
|
||||||
|
@ -1,11 +1,27 @@
|
|||||||
|
.ts-wrapper.multi .ts-control {
|
||||||
|
min-width: calc(100% - 0.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
/* This also requires ajax-select-index.css */
|
/* This also requires ajax-select-index.css */
|
||||||
.ts-dropdown {
|
.ts-dropdown {
|
||||||
|
width: calc(100% - 0.2rem);
|
||||||
|
left: 0.1rem;
|
||||||
|
top: calc(100% - 0.2rem - var(--nf-input-border-bottom-width));
|
||||||
|
border: var(--nf-input-border-color) var(--nf-input-border-width) solid;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom-width: var(--nf-input-border-bottom-width);
|
||||||
|
|
||||||
|
.option.active {
|
||||||
|
background-color: #e5eafa;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.select-item {
|
.select-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@ -16,19 +32,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-wrapper {
|
.ts-wrapper.single {
|
||||||
margin: 5px;
|
> .ts-control {
|
||||||
|
box-shadow: none;
|
||||||
|
max-width: 300px;
|
||||||
|
background-color: var(--nf-input-background-color);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .ts-dropdown {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-wrapper.single {
|
.ts-wrapper input[type="text"] {
|
||||||
width: 263px; // same length as regular text inputs
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-wrapper.multi, .ts-wrapper.single {
|
||||||
|
.ts-control:has(input:focus) {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nf-input-focus-border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
|
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
|
||||||
border-left: 1px solid #aaa;
|
border-left: 1px solid #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-wrapper.multi .ts-control {
|
.ts-wrapper.multi.has-items .ts-control {
|
||||||
|
padding: calc(var(--nf-input-size) * 0.65);
|
||||||
|
display: flex;
|
||||||
|
gap: calc(var(--nf-input-size) / 3);
|
||||||
|
|
||||||
[data-value],
|
[data-value],
|
||||||
[data-value].active {
|
[data-value].active {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
@ -37,19 +78,17 @@
|
|||||||
border: 1px solid #aaa;
|
border: 1px solid #aaa;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: 5px;
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-dropdown {
|
.ts-wrapper.focus .ts-control {
|
||||||
.option.active {
|
box-shadow: none;
|
||||||
background-color: #e5eafa;
|
}
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
96
core/static/core/components/card.scss
Normal file
96
core/static/core/components/card.scss
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
@import "core/static/core/colors";
|
||||||
|
|
||||||
|
@mixin row-layout {
|
||||||
|
min-height: 100px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 10px;
|
||||||
|
.card-image {
|
||||||
|
max-width: 75px;
|
||||||
|
}
|
||||||
|
.card-content {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: $primary-neutral-light-color;
|
||||||
|
border-radius: 5px;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 20px 10px;
|
||||||
|
height: fit-content;
|
||||||
|
width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
&.clickable:hover {
|
||||||
|
background-color: darken($primary-neutral-light-color, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
animation: bg-in-out 1s ease;
|
||||||
|
background-color: rgb(216, 236, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 70px;
|
||||||
|
max-height: 70px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
i.card-image {
|
||||||
|
color: black;
|
||||||
|
text-align: center;
|
||||||
|
background-color: rgba(173, 173, 173, 0.2);
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
color: black;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bg-in-out {
|
||||||
|
0% {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: rgb(216, 236, 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 765px) {
|
||||||
|
@include row-layout
|
||||||
|
}
|
||||||
|
|
||||||
|
// When combined with card, card-row display the card in a row layout,
|
||||||
|
// whatever the size of the screen.
|
||||||
|
&.card-row {
|
||||||
|
@include row-layout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
5
core/static/core/devices.scss
Normal file
5
core/static/core/devices.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
||||||
|
|
||||||
|
$small-devices: 576px;
|
||||||
|
$medium-devices: 768px;
|
||||||
|
$large-devices: 992px;
|
@ -1,89 +1,730 @@
|
|||||||
@import "colors";
|
@import "colors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Style related to forms
|
* Style related to forms and form inputs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
a.button,
|
/**
|
||||||
button,
|
* Inputs that are not enclosed in a form element.
|
||||||
input[type="button"],
|
*/
|
||||||
input[type="submit"],
|
:not(form) {
|
||||||
input[type="reset"],
|
a.button,
|
||||||
input[type="file"] {
|
button,
|
||||||
border: none;
|
input[type="button"],
|
||||||
text-decoration: none;
|
input[type="submit"],
|
||||||
background-color: $background-button-color;
|
input[type="reset"],
|
||||||
padding: 0.4em;
|
input[type="file"] {
|
||||||
margin: 0.1em;
|
border: none;
|
||||||
font-size: 1.2em;
|
text-decoration: none;
|
||||||
border-radius: 5px;
|
background-color: $background-button-color;
|
||||||
color: black;
|
padding: 0.4em;
|
||||||
|
margin: 0.1em;
|
||||||
|
font-size: 1.2em;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: black;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: hsl(0, 0%, 83%);
|
background: hsl(0, 0%, 83%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button,
|
||||||
|
input[type="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="file"] {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button:not(:disabled),
|
||||||
|
button:not(:disabled),
|
||||||
|
input[type="button"]:not(:disabled),
|
||||||
|
input[type="submit"]:not(:disabled),
|
||||||
|
input[type="reset"]:not(:disabled),
|
||||||
|
input[type="checkbox"]:not(:disabled),
|
||||||
|
input[type="file"]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea[type="text"],
|
||||||
|
[type="number"],
|
||||||
|
.ts-control {
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: $background-button-color;
|
||||||
|
padding: 0.4em;
|
||||||
|
margin: 0.1em;
|
||||||
|
font-size: 1.2em;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: $background-button-color;
|
||||||
|
padding: 7px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, .ts-control {
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.2em;
|
||||||
|
background-color: $background-button-color;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not(.button) {
|
||||||
|
text-decoration: none;
|
||||||
|
color: $primary-dark-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-light-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.button,
|
form {
|
||||||
input[type="button"],
|
// Input size - used for height/padding calculations
|
||||||
input[type="submit"],
|
--nf-input-size: 1rem;
|
||||||
input[type="reset"],
|
|
||||||
input[type="file"] {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.button:not(:disabled),
|
--nf-input-font-size: calc(var(--nf-input-size) * 0.875);
|
||||||
button:not(:disabled),
|
--nf-small-font-size: calc(var(--nf-input-size) * 0.875);
|
||||||
input[type="button"]:not(:disabled),
|
|
||||||
input[type="submit"]:not(:disabled),
|
|
||||||
input[type="reset"]:not(:disabled),
|
|
||||||
input[type="checkbox"]:not(:disabled),
|
|
||||||
input[type="file"]:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
// Input
|
||||||
textarea[type="text"],
|
--nf-input-color: $text-color;
|
||||||
[type="number"] {
|
--nf-input-border-radius: 0.25rem;
|
||||||
border: none;
|
--nf-input-placeholder-color: #929292;
|
||||||
text-decoration: none;
|
--nf-input-border-color: #c0c4c9;
|
||||||
background-color: $background-button-color;
|
--nf-input-border-width: 1px;
|
||||||
padding: 0.4em;
|
--nf-input-border-style: solid;
|
||||||
margin: 0.1em;
|
--nf-input-border-bottom-width: 2px;
|
||||||
font-size: 1.2em;
|
--nf-input-focus-border-color: #3b4ce2;
|
||||||
border-radius: 5px;
|
--nf-input-background-color: #f3f6f7;
|
||||||
max-width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
// Valid/invalid
|
||||||
border: none;
|
--nf-invalid-input-border-color: var(--nf-input-border-color);
|
||||||
text-decoration: none;
|
--nf-invalid-input-background-color: var(--nf-input-background-color);
|
||||||
background-color: $background-button-color;
|
--nf-invalid-input-color: var(--nf-input-color);
|
||||||
padding: 7px;
|
--nf-valid-input-border-color: var(--nf-input-border-color);
|
||||||
font-size: 1.2em;
|
--nf-valid-input-background-color: var(--nf-input-background-color);
|
||||||
border-radius: 5px;
|
--nf-valid-input-color: inherit;
|
||||||
font-family: sans-serif;
|
--nf-invalid-input-border-bottom-color: red;
|
||||||
}
|
--nf-valid-input-border-bottom-color: green;
|
||||||
|
|
||||||
select {
|
// Label variables
|
||||||
border: none;
|
--nf-label-font-size: var(--nf-small-font-size);
|
||||||
text-decoration: none;
|
--nf-label-color: #374151;
|
||||||
font-size: 1.2em;
|
--nf-label-font-weight: 500;
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:not(.button) {
|
// Slider variables
|
||||||
text-decoration: none;
|
--nf-slider-track-background: #dfdfdf;
|
||||||
color: $primary-dark-color;
|
--nf-slider-track-height: 0.25rem;
|
||||||
|
--nf-slider-thumb-size: calc(var(--nf-slider-track-height) * 4);
|
||||||
|
--nf-slider-track-border-radius: var(--nf-slider-track-height);
|
||||||
|
--nf-slider-thumb-border-width: 2px;
|
||||||
|
--nf-slider-thumb-border-focus-width: 1px;
|
||||||
|
--nf-slider-thumb-border-color: #ffffff;
|
||||||
|
--nf-slider-thumb-background: var(--nf-input-focus-border-color);
|
||||||
|
|
||||||
&:hover {
|
display: block;
|
||||||
color: $primary-light-color;
|
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.helptext {
|
||||||
|
margin-top: .25rem;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
font-size: 80%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
fieldset {
|
||||||
color: $primary-color;
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
label {
|
||||||
|
margin: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------- LABEL
|
||||||
|
label, legend {
|
||||||
|
font-weight: var(--nf-label-font-weight);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: calc(var(--nf-input-size) / 2);
|
||||||
|
white-space: initial;
|
||||||
|
|
||||||
|
+ small {
|
||||||
|
font-style: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.required:after {
|
||||||
|
margin-left: 4px;
|
||||||
|
content: "*";
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrap texts
|
||||||
|
label, legend, ul.errorlist > li, .helptext {
|
||||||
|
text-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choose_file_widget {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------- SMALL
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
font-weight: normal;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: var(--nf-small-font-size);
|
||||||
|
margin-bottom: calc(var(--nf-input-size) * 0.75);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group,
|
||||||
|
> p,
|
||||||
|
> div {
|
||||||
|
margin-top: calc(var(--nf-input-size) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ ERROR LIST
|
||||||
|
ul.errorlist {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 60%;
|
||||||
|
color: var(--nf-invalid-input-border-bottom-color);
|
||||||
|
|
||||||
|
> li {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(.ts-control) > {
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="tel"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="week"],
|
||||||
|
input[type="time"],
|
||||||
|
input[type="search"],
|
||||||
|
textarea,
|
||||||
|
input[type="month"],
|
||||||
|
select {
|
||||||
|
min-width: 300px;
|
||||||
|
|
||||||
|
&.grow {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="tel"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="week"],
|
||||||
|
input[type="time"],
|
||||||
|
input[type="month"],
|
||||||
|
input[type="search"],
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
.ts-control {
|
||||||
|
background: var(--nf-input-background-color);
|
||||||
|
font-size: var(--nf-input-font-size);
|
||||||
|
border-color: var(--nf-input-border-color);
|
||||||
|
border-width: var(--nf-input-border-width);
|
||||||
|
border-style: var(--nf-input-border-style);
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: var(--nf-input-border-radius);
|
||||||
|
border-bottom-width: var(--nf-input-border-bottom-width);
|
||||||
|
color: var(--nf-input-color);
|
||||||
|
max-width: 95%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: calc(var(--nf-input-size) * 0.65);
|
||||||
|
line-height: normal;
|
||||||
|
appearance: none;
|
||||||
|
transition: all 0.15s ease-out;
|
||||||
|
|
||||||
|
// ------------- VALID/INVALID
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
&:not(:placeholder-shown):invalid {
|
||||||
|
background-color: var(--nf-invalid-input-background-color);
|
||||||
|
border-color: var(--nf-valid-input-border-color);
|
||||||
|
border-bottom-color: var(--nf-invalid-input-border-bottom-color);
|
||||||
|
color: var(--nf-invalid-input-color);
|
||||||
|
|
||||||
|
// Reset to default when focus
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: var(--nf-input-background-color);
|
||||||
|
border-color: var(--nf-input-border-color);
|
||||||
|
color: var(--nf-input-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:placeholder-shown):valid {
|
||||||
|
background-color: var(--nf-valid-input-background-color);
|
||||||
|
border-color: var(--nf-valid-input-border-color);
|
||||||
|
border-bottom-color: var(--nf-valid-input-border-bottom-color);
|
||||||
|
color: var(--nf-valid-input-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------- DISABLED
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- PLACEHOLDERS
|
||||||
|
|
||||||
|
&::-webkit-input-placeholder {
|
||||||
|
color: var(--nf-input-placeholder-color);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:-ms-input-placeholder {
|
||||||
|
color: var(--nf-input-placeholder-color);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-placeholder {
|
||||||
|
color: var(--nf-input-placeholder-color);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:-moz-placeholder {
|
||||||
|
color: var(--nf-input-placeholder-color);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- FOCUS
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nf-input-focus-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- ADDITIONAL TEXT BENEATH INPUT FIELDS
|
||||||
|
|
||||||
|
+ small {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- ICONS
|
||||||
|
|
||||||
|
--icon-padding: calc(var(--nf-input-size) * 2.25);
|
||||||
|
--icon-background-offset: calc(var(--nf-input-size) * 0.75);
|
||||||
|
|
||||||
|
&.icon-left {
|
||||||
|
background-position: left var(--icon-background-offset) bottom 50%;
|
||||||
|
padding-left: var(--icon-padding);
|
||||||
|
background-size: var(--nf-input-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.icon-right {
|
||||||
|
background-position: right var(--icon-background-offset) bottom 50%;
|
||||||
|
padding-right: var(--icon-padding);
|
||||||
|
background-size: var(--nf-input-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a field has a icon and is autofilled, the background image is removed
|
||||||
|
// by the browser. To negate this we reset the padding, not great but okay
|
||||||
|
|
||||||
|
&:-webkit-autofill {
|
||||||
|
padding: calc(var(--nf-input-size) * 0.75) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- SEARCH
|
||||||
|
|
||||||
|
input[type="search"] {
|
||||||
|
&:placeholder-shown {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-search'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
|
||||||
|
background-position: left calc(var(--nf-input-size) * 0.75) bottom 50%;
|
||||||
|
padding-left: calc(var(--nf-input-size) * 2.25);
|
||||||
|
background-size: var(--nf-input-size);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-search-cancel-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: var(--nf-input-size);
|
||||||
|
height: var(--nf-input-size);
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
padding-left: calc(var(--nf-input-size) * 0.75);
|
||||||
|
background-position: left calc(var(--nf-input-size) * -1) bottom 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- EMAIL
|
||||||
|
|
||||||
|
input[type="email"][class^="icon"] {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-at-sign'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- TEL
|
||||||
|
|
||||||
|
input[type="tel"][class^="icon"] {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-phone'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- URL
|
||||||
|
|
||||||
|
input[type="url"][class^="icon"] {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-link'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- PASSWORD
|
||||||
|
|
||||||
|
input[type="password"] {
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&[class^="icon"] {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-lock'%3E%3Crect x='3' y='11' width='18' height='11' rx='2' ry='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- RANGE
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: for some reason grouping these doesn't work (just like :placeholder)
|
||||||
|
|
||||||
|
@mixin track {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--nf-slider-track-height);
|
||||||
|
background: var(--nf-slider-track-background);
|
||||||
|
border-radius: var(--nf-slider-track-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin thumb {
|
||||||
|
height: var(--nf-slider-thumb-size);
|
||||||
|
width: var(--nf-slider-thumb-size);
|
||||||
|
border-radius: var(--nf-slider-thumb-size);
|
||||||
|
background: var(--nf-slider-thumb-background);
|
||||||
|
border: 0;
|
||||||
|
border: var(--nf-slider-thumb-border-width) solid var(--nf-slider-thumb-border-color);
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin thumb-focus {
|
||||||
|
box-shadow: 0 0 0 var(--nf-slider-thumb-border-focus-width) var(--nf-slider-thumb-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-runnable-track {
|
||||||
|
@include track;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-track {
|
||||||
|
@include track;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
@include thumb;
|
||||||
|
margin-top: calc(
|
||||||
|
(
|
||||||
|
calc(var(--nf-slider-track-height) - var(--nf-slider-thumb-size)) *
|
||||||
|
0.5
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
@include thumb;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus::-webkit-slider-thumb {
|
||||||
|
@include thumb-focus;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus::-moz-range-thumb {
|
||||||
|
@include thumb-focus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- COLOR
|
||||||
|
|
||||||
|
input[type="color"] {
|
||||||
|
border: var(--nf-input-border-width) solid var(--nf-input-border-color);
|
||||||
|
border-bottom-width: var(--nf-input-border-bottom-width);
|
||||||
|
height: calc(var(--nf-input-size) * 2);
|
||||||
|
border-radius: var(--nf-input-border-radius);
|
||||||
|
padding: calc(var(--nf-input-border-width) * 2);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nf-input-focus-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin swatch {
|
||||||
|
border-radius: calc(var(--nf-input-border-radius) / 2);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-color-swatch {
|
||||||
|
@include swatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-color-swatch {
|
||||||
|
@include swatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- NUMBER
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- DATES
|
||||||
|
|
||||||
|
input[type="date"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="week"],
|
||||||
|
input[type="month"] {
|
||||||
|
min-width: 300px;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-calendar'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="time"] {
|
||||||
|
min-width: 6em;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-clock'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="date"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="week"],
|
||||||
|
input[type="time"],
|
||||||
|
input[type="month"] {
|
||||||
|
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: var(--nf-input-size);
|
||||||
|
|
||||||
|
&::-webkit-inner-spin-button,
|
||||||
|
&::-webkit-calendar-picker-indicator {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FireFox reset
|
||||||
|
// FF has restricted control of styling the date/time inputs.
|
||||||
|
// That's why we don't show icons for FF users, and leave basic styling in place.
|
||||||
|
@-moz-document url-prefix() {
|
||||||
|
min-width: auto;
|
||||||
|
width: auto;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- TEXAREA
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- CHECKBOX/RADIO
|
||||||
|
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: var(--nf-input-size);
|
||||||
|
height: var(--nf-input-size);
|
||||||
|
padding: inherit;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
border-radius: calc(var(--nf-input-border-radius) / 2);
|
||||||
|
border-width: var(--nf-input-border-width);
|
||||||
|
cursor: pointer;
|
||||||
|
background-position: center center;
|
||||||
|
|
||||||
|
&:focus:not(:checked) {
|
||||||
|
border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
+ label {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: calc(var(--nf-input-size) / 2.5);
|
||||||
|
font-weight: normal;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: calc(100% - calc(var(--nf-input-size) * 2));
|
||||||
|
line-height: normal;
|
||||||
|
|
||||||
|
> small {
|
||||||
|
margin-top: calc(var(--nf-input-size) / 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
&:checked {
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23FFFFFF' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-check'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") no-repeat center center/85%;
|
||||||
|
background-color: var(--nf-input-focus-border-color);
|
||||||
|
border-color: var(--nf-input-focus-border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="radio"] {
|
||||||
|
border-radius: 100%;
|
||||||
|
|
||||||
|
&:checked {
|
||||||
|
background-color: var(--nf-input-focus-border-color);
|
||||||
|
border-color: var(--nf-input-focus-border-color);
|
||||||
|
box-shadow: 0 0 0 3px white inset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- SWITCH
|
||||||
|
|
||||||
|
--switch-orb-size: var(--nf-input-size);
|
||||||
|
--switch-orb-offset: calc(var(--nf-input-border-width) * 2);
|
||||||
|
--switch-width: calc(var(--nf-input-size) * 2.5);
|
||||||
|
--switch-height: calc(
|
||||||
|
calc(var(--nf-input-size) * 1.25) + var(--switch-orb-offset)
|
||||||
|
);
|
||||||
|
|
||||||
|
input[type="checkbox"].switch {
|
||||||
|
width: var(--switch-width);
|
||||||
|
height: var(--switch-height);
|
||||||
|
border-radius: var(--switch-height);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: var(--nf-input-border-color);
|
||||||
|
border-radius: var(--switch-orb-size);
|
||||||
|
height: var(--switch-orb-size);
|
||||||
|
left: var(--switch-orb-offset);
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: var(--switch-orb-size);
|
||||||
|
content: "";
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ label {
|
||||||
|
margin-top: calc(var(--switch-height) / 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked {
|
||||||
|
background: var(--nf-input-focus-border-color) none initial;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
transform: translateY(-50%) translateX(
|
||||||
|
calc(calc(var(--switch-width) / 2) - var(--switch-orb-offset))
|
||||||
|
);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- FILE
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
background: rgba(0, 0, 0, 0.025);
|
||||||
|
padding: calc(var(--nf-input-size) / 2);
|
||||||
|
display: block;
|
||||||
|
font-weight: normal;
|
||||||
|
width: 95%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: var(--nf-input-border-radius);
|
||||||
|
border: 1px dashed var(--nf-input-border-color);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--nf-input-focus-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin button {
|
||||||
|
background: var(--nf-input-focus-border-color);
|
||||||
|
border: 0;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: var(--nf-input-border-radius);
|
||||||
|
color: white;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::file-selector-button {
|
||||||
|
@include button();
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-file-upload-button {
|
||||||
|
@include button();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- SELECT
|
||||||
|
|
||||||
|
select,
|
||||||
|
.ts-wrapper.multi .ts-control,
|
||||||
|
.ts-wrapper.single .ts-control,
|
||||||
|
.ts-wrapper.single.input-active .ts-control {
|
||||||
|
background-color: var(--nf-input-background-color);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: var(--nf-input-size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
>a {
|
> a {
|
||||||
color: $text-color;
|
color: $text-color!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover>a {
|
&:hover>a {
|
||||||
@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d;
|
|||||||
}
|
}
|
||||||
|
|
||||||
>input[type=text] {
|
>input[type=text] {
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
border: unset;
|
||||||
height: 35px;
|
height: 35px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-size: .9em;
|
font-size: .9em;
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
@import "colors";
|
@import "colors";
|
||||||
@import "forms";
|
@import "forms";
|
||||||
|
@import "devices";
|
||||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
|
||||||
$small-devices: 576px;
|
|
||||||
$medium-devices: 768px;
|
|
||||||
$large-devices: 992px;
|
|
||||||
|
|
||||||
/*--------------------------------GENERAL------------------------------*/
|
/*--------------------------------GENERAL------------------------------*/
|
||||||
|
|
||||||
@ -19,6 +15,13 @@ body {
|
|||||||
--loading-stroke: 5px;
|
--loading-stroke: 5px;
|
||||||
--loading-duration: 1s;
|
--loading-duration: 1s;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
&.aria-busy-grow {
|
||||||
|
// Make sure the element take enough place to hold the loading wheel
|
||||||
|
min-height: calc((var(--loading-size)) * 1.5);
|
||||||
|
min-width: calc((var(--loading-size)) * 1.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[aria-busy]:after {
|
[aria-busy]:after {
|
||||||
@ -128,6 +131,10 @@ body {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[show-more]:not([show-more-loaded]) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/*--------------------------------HEADER-------------------------------*/
|
/*--------------------------------HEADER-------------------------------*/
|
||||||
|
|
||||||
#popupheader {
|
#popupheader {
|
||||||
@ -198,6 +205,10 @@ body {
|
|||||||
margin: 20px auto 0;
|
margin: 20px auto 0;
|
||||||
|
|
||||||
/*---------------------------------NAV---------------------------------*/
|
/*---------------------------------NAV---------------------------------*/
|
||||||
|
a.btn {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@ -252,6 +263,13 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A spacer below an element. Somewhat cleaner than putting <br/> everywhere.
|
||||||
|
*/
|
||||||
|
.margin-bottom {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/*--------------------------------CONTENT------------------------------*/
|
/*--------------------------------CONTENT------------------------------*/
|
||||||
#quick_notif {
|
#quick_notif {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -314,6 +332,18 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snackbar {
|
||||||
|
width: 250px;
|
||||||
|
margin-left: -125px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10;
|
||||||
|
/* to get on top of tomselect */
|
||||||
|
left: 50%;
|
||||||
|
top: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
@ -398,302 +428,31 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*---------------------------------NEWS--------------------------------*/
|
.row {
|
||||||
#news {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
$col-gap: 1rem;
|
||||||
flex-direction: column;
|
$row-gap: 0.5rem;
|
||||||
|
|
||||||
|
&.gap {
|
||||||
|
column-gap: var($col-gap);
|
||||||
|
row-gap: var($row-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.news_column {
|
@for $i from 2 through 5 {
|
||||||
display: inline-block;
|
&.gap-#{$i}x {
|
||||||
margin: 0;
|
column-gap: $i * $col-gap;
|
||||||
vertical-align: top;
|
row-gap: $i * $row-gap;
|
||||||
}
|
|
||||||
|
|
||||||
#news_admin {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#right_column {
|
|
||||||
flex: 20%;
|
|
||||||
float: right;
|
|
||||||
margin: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#left_column {
|
|
||||||
flex: 79%;
|
|
||||||
margin: 0.2em;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
background: $second-color;
|
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
|
||||||
padding: 0.4em;
|
|
||||||
margin: 0 0 0.5em 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 1.1em;
|
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
|
||||||
margin: 2em 0 1em 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $small-devices) {
|
// Make an element of the row take as much space as needed
|
||||||
|
.grow {
|
||||||
#left_column,
|
flex: 1;
|
||||||
#right_column {
|
|
||||||
flex: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AGENDA/BIRTHDAYS */
|
|
||||||
#agenda,
|
|
||||||
#birthdays {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
background: white;
|
|
||||||
font-size: 70%;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
|
|
||||||
#agenda_title,
|
|
||||||
#birthdays_title {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
|
||||||
padding: 0.5em;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 150%;
|
|
||||||
text-align: center;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: $second-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
#agenda_content {
|
|
||||||
overflow: auto;
|
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
|
||||||
height: 20em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#agenda_content,
|
|
||||||
#birthdays_content {
|
|
||||||
.agenda_item {
|
|
||||||
padding: 0.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
|
|
||||||
&:nth-of-type(even) {
|
|
||||||
background: $secondary-neutral-light-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agenda_time {
|
|
||||||
font-size: 90%;
|
|
||||||
color: grey;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agenda_item_content {
|
|
||||||
p {
|
|
||||||
margin-top: 0.2em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.birthdays_year {
|
|
||||||
margin: 0;
|
|
||||||
list-style-type: none;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
>li {
|
|
||||||
padding: 0.5em;
|
|
||||||
|
|
||||||
&:nth-child(even) {
|
|
||||||
background: $secondary-neutral-light-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
margin-left: 1em;
|
|
||||||
list-style-type: square;
|
|
||||||
list-style-position: inside;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* END AGENDA/BIRTHDAYS */
|
|
||||||
|
|
||||||
/* EVENTS TODAY AND NEXT FEW DAYS */
|
|
||||||
.news_events_group {
|
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
|
||||||
margin-left: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
|
|
||||||
.news_events_group_date {
|
|
||||||
display: table-cell;
|
|
||||||
padding: 0.6em;
|
|
||||||
vertical-align: middle;
|
|
||||||
background: $primary-neutral-dark-color;
|
|
||||||
color: $white-color;
|
|
||||||
text-transform: uppercase;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 1.4em;
|
|
||||||
border-radius: 7px 0 0 7px;
|
|
||||||
|
|
||||||
div {
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
.day {
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_events_group_items {
|
|
||||||
display: table-cell;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.news_event:nth-of-type(odd) {
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_event:nth-of-type(even) {
|
|
||||||
background: $primary-neutral-light-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_event {
|
|
||||||
display: block;
|
|
||||||
padding: 0.4em;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: 1px solid grey;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
margin: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin-top: 1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.club_logo {
|
|
||||||
float: left;
|
|
||||||
min-width: 7em;
|
|
||||||
max-width: 9em;
|
|
||||||
margin: 0;
|
|
||||||
margin-right: 1em;
|
|
||||||
margin-top: 0.8em;
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-height: 6em;
|
|
||||||
max-width: 8em;
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_date {
|
|
||||||
font-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_content {
|
|
||||||
clear: left;
|
|
||||||
|
|
||||||
.button_bar {
|
|
||||||
text-align: right;
|
|
||||||
|
|
||||||
.fb {
|
|
||||||
color: $faceblue;
|
|
||||||
}
|
|
||||||
|
|
||||||
.twitter {
|
|
||||||
color: $twitblue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* END EVENTS TODAY AND NEXT FEW DAYS */
|
|
||||||
|
|
||||||
/* COMING SOON */
|
|
||||||
.news_coming_soon {
|
|
||||||
display: list-item;
|
|
||||||
list-style-type: square;
|
|
||||||
list-style-position: inside;
|
|
||||||
margin-left: 1em;
|
|
||||||
padding-left: 0;
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_date {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* END COMING SOON */
|
|
||||||
|
|
||||||
/* NOTICES */
|
|
||||||
.news_notice {
|
|
||||||
margin: 0 0 1em 1em;
|
|
||||||
padding: 0.4em;
|
|
||||||
padding-left: 1em;
|
|
||||||
background: $secondary-neutral-light-color;
|
|
||||||
box-shadow: $shadow-color 0 0 2px;
|
|
||||||
border-radius: 18px 5px 18px 5px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_content {
|
|
||||||
margin-left: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* END NOTICES */
|
|
||||||
|
|
||||||
/* CALLS */
|
|
||||||
.news_call {
|
|
||||||
margin: 0 0 1em 1em;
|
|
||||||
padding: 0.4em;
|
|
||||||
padding-left: 1em;
|
|
||||||
background: $secondary-neutral-light-color;
|
|
||||||
border: 1px solid grey;
|
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_date {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_content {
|
|
||||||
margin-left: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* END CALLS */
|
|
||||||
|
|
||||||
.news_empty {
|
|
||||||
margin-left: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_date {
|
|
||||||
color: grey;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $small-devices) {
|
@media screen and (max-width: $small-devices) {
|
||||||
@ -702,304 +461,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#news_details {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 0.4em;
|
|
||||||
width: 80%;
|
|
||||||
background: $white-color;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin-top: 1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.club_logo {
|
|
||||||
display: inline-block;
|
|
||||||
text-align: center;
|
|
||||||
width: 19%;
|
|
||||||
float: left;
|
|
||||||
min-width: 15em;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-height: 15em;
|
|
||||||
max-width: 12em;
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.share_button {
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 0.5em 1em;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1.2em;
|
|
||||||
border-radius: 2px;
|
|
||||||
float: right;
|
|
||||||
display: block;
|
|
||||||
margin-left: 0.3em;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: lightgrey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.facebook {
|
|
||||||
background: $faceblue;
|
|
||||||
}
|
|
||||||
|
|
||||||
.twitter {
|
|
||||||
background: $twitblue;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_meta {
|
|
||||||
margin-top: 10em;
|
|
||||||
font-size: small;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.helptext {
|
|
||||||
margin-top: 10px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*---------------------------POSTERS----------------------------*/
|
|
||||||
|
|
||||||
#poster_list,
|
|
||||||
#screen_list,
|
|
||||||
#poster_edit,
|
|
||||||
#screen_edit {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
#title {
|
|
||||||
position: relative;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 10px;
|
|
||||||
border-bottom: 2px solid black;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#links {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
bottom: 5px;
|
|
||||||
|
|
||||||
&.left {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.right {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
padding: 5px;
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
margin-left: 5px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background-color: hsl(40, 100%, 50%);
|
|
||||||
color: black;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: black;
|
|
||||||
background-color: hsl(40, 58%, 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.delete {
|
|
||||||
background-color: hsl(0, 100%, 40%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#posters,
|
|
||||||
#screens {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
#no-posters,
|
|
||||||
#no-screens {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poster,
|
|
||||||
.screen {
|
|
||||||
min-width: 10%;
|
|
||||||
max-width: 20%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 10px;
|
|
||||||
border: 2px solid darkgrey;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 10px;
|
|
||||||
background-color: lightgrey;
|
|
||||||
|
|
||||||
* {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
padding-bottom: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
border-bottom: 1px solid whitesmoke;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
flex-grow: 1;
|
|
||||||
position: relative;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
border-bottom: 1px solid whitesmoke;
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-height: 20vw;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
&::before {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10;
|
|
||||||
content: "Click to expand";
|
|
||||||
color: white;
|
|
||||||
background-color: rgba(black, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dates {
|
|
||||||
padding-bottom: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
border-bottom: 1px solid whitesmoke;
|
|
||||||
|
|
||||||
* {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-left: 5px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.begin,
|
|
||||||
.end {
|
|
||||||
width: 48%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.begin {
|
|
||||||
border-right: 1px solid whitesmoke;
|
|
||||||
padding-right: 2%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit,
|
|
||||||
.moderate,
|
|
||||||
.slideshow {
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background-color: hsl(40, 100%, 50%);
|
|
||||||
color: black;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: black;
|
|
||||||
background-color: hsl(40, 58%, 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(2n) {
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
visibility: hidden;
|
|
||||||
width: 120px;
|
|
||||||
background-color: hsl(210, 20%, 98%);
|
|
||||||
color: hsl(0, 0%, 0%);
|
|
||||||
text-align: center;
|
|
||||||
padding: 5px 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin-left: 0;
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: list-item;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.not_moderated {
|
|
||||||
border: 1px solid red;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .tooltip {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#view {
|
|
||||||
position: fixed;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10;
|
|
||||||
visibility: hidden;
|
|
||||||
background-color: rgba(10, 10, 10, 0.9);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
#placeholder {
|
|
||||||
width: 80vw;
|
|
||||||
height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*---------------------------ACCOUNTING----------------------------*/
|
/*---------------------------ACCOUNTING----------------------------*/
|
||||||
#accounting {
|
#accounting {
|
||||||
.journal-table {
|
.journal-table {
|
||||||
@ -1199,40 +660,6 @@ u,
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bar-ui {
|
|
||||||
padding: 0.4em;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
|
|
||||||
#products {
|
|
||||||
flex-basis: 100%;
|
|
||||||
margin: 0.2em;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#click_form {
|
|
||||||
flex: auto;
|
|
||||||
margin: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#user_info {
|
|
||||||
flex: auto;
|
|
||||||
padding: 0.5em;
|
|
||||||
margin: 0.2em;
|
|
||||||
height: 100%;
|
|
||||||
background: $secondary-neutral-light-color;
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 70%;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*-----------------------------USER PROFILE----------------------------*/
|
/*-----------------------------USER PROFILE----------------------------*/
|
||||||
|
|
||||||
.user_mini_profile {
|
.user_mini_profile {
|
||||||
@ -1399,23 +826,12 @@ footer {
|
|||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: rgba(0, 0, 0, 0.3);
|
color: rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fa-github {
|
||||||
|
color: $githubblack;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*---------------------------------FORMS-------------------------------*/
|
|
||||||
|
|
||||||
form {
|
|
||||||
margin: 0 auto;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choose_file_widget {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-dialog .ui-dialog-buttonpane {
|
.ui-dialog .ui-dialog-buttonpane {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@ -1423,16 +839,6 @@ label {
|
|||||||
width: 97%;
|
width: 97%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#user_edit {
|
|
||||||
* {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#cash_summary_form label,
|
#cash_summary_form label,
|
||||||
.inline {
|
.inline {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@import "core/static/core/colors";
|
||||||
|
|
||||||
main {
|
main {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -69,7 +71,7 @@ main {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: #f2f2f2;
|
background-color: $primary-neutral-light-color;
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
font-size: small;
|
font-size: small;
|
||||||
|
@ -1,26 +1,9 @@
|
|||||||
|
|
||||||
@media (max-width: 750px) {
|
@media (max-width: 750px) {
|
||||||
.title {
|
.title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-error {
|
|
||||||
height: auto !important;
|
|
||||||
|
|
||||||
> ul {
|
|
||||||
list-style-type: none;
|
|
||||||
margin: 0;
|
|
||||||
color: indianred;
|
|
||||||
|
|
||||||
> li {
|
|
||||||
text-align: left !important;
|
|
||||||
line-height: normal;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile {
|
.profile {
|
||||||
&-visible {
|
&-visible {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -87,11 +70,7 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
> i {
|
> p {
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
>p {
|
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
@ -107,16 +86,6 @@
|
|||||||
> div {
|
> div {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
> input {
|
|
||||||
font-weight: normal;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
> button {
|
|
||||||
min-width: 30%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 750px) {
|
@media (min-width: 750px) {
|
||||||
height: auto;
|
height: auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -124,8 +93,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
> input {
|
> input {
|
||||||
width: 70%;
|
|
||||||
font-size: .6em;
|
font-size: .6em;
|
||||||
|
|
||||||
&::file-selector-button {
|
&::file-selector-button {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
@ -167,7 +136,7 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
>* {
|
> * {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
|
|
||||||
@ -181,45 +150,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
|
> * {
|
||||||
>* {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
line-height: 40px;
|
|
||||||
max-width: 100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
>* {
|
> * {
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
>textarea {
|
height: 7rem;
|
||||||
height: 120px;
|
}
|
||||||
min-height: 40px;
|
.final-actions {
|
||||||
min-width: 300px;
|
text-align: center;
|
||||||
max-width: 300px;
|
|
||||||
line-height: initial;
|
|
||||||
|
|
||||||
@media (max-width: 750px) {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
>input[type="file"] {
|
|
||||||
font-size: small;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
>input[type="checkbox"] {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin: 0;
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -108,7 +108,8 @@
|
|||||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||||
</div>
|
</div>
|
||||||
<a href="https://discord.gg/XK9WfPsUFm" target="_link">
|
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
|
||||||
|
<i class="fa-brands fa-github"></i>
|
||||||
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -124,15 +125,14 @@
|
|||||||
navbar.style.setProperty("display", current === "none" ? "block" : "none");
|
navbar.style.setProperty("display", current === "none" ? "block" : "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).keydown(function (e) {
|
document.addEventListener("keydown", (e) => {
|
||||||
if ($(e.target).is('input')) { return }
|
// Looking at the `s` key when not typing in a form
|
||||||
if ($(e.target).is('textarea')) { return }
|
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
|
||||||
if ($(e.target).is('select')) { return }
|
return;
|
||||||
if (e.keyCode === 83) {
|
|
||||||
$("#search").focus();
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
});
|
document.getElementById("search").focus();
|
||||||
|
e.preventDefault(); // Don't type the character in the focused search input
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
@ -57,13 +57,4 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
{{ super() }}
|
|
||||||
{% if popup %}
|
|
||||||
<script>
|
|
||||||
parent.$(".choose_file_widget").css("height", "75%");
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.date_of_birth %}
|
{% if user.date_of_birth %}
|
||||||
<div class="user_mini_profile_dob">
|
<div class="user_mini_profile_dob">
|
||||||
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }})
|
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -140,7 +140,7 @@
|
|||||||
nb_page (str): call to a javascript function or variable returning
|
nb_page (str): call to a javascript function or variable returning
|
||||||
the maximum number of pages to paginate
|
the maximum number of pages to paginate
|
||||||
#}
|
#}
|
||||||
<nav class="pagination" x-show="{{ nb_pages }} > 1">
|
<nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
|
||||||
{# Adding the prevent here is important, because otherwise,
|
{# Adding the prevent here is important, because otherwise,
|
||||||
clicking on the pagination buttons could submit the picture management form
|
clicking on the pagination buttons could submit the picture management form
|
||||||
and reload the page #}
|
and reload the page #}
|
||||||
|
@ -3,17 +3,18 @@
|
|||||||
{% macro page_history(page) %}
|
{% macro page_history(page) %}
|
||||||
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
|
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
|
||||||
<ul>
|
<ul>
|
||||||
{% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
|
{% set page_name = page.get_full_name() %}
|
||||||
{% if loop.index < 2 %}
|
{%- for rev in page.revisions.order_by("-date").select_related("author") -%}
|
||||||
<li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
|
<li>
|
||||||
{{ user_profile_link(page.revisions.last().author) }} -
|
{% if loop.first %}
|
||||||
{{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
|
<a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
|
<a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
|
||||||
{{ user_profile_link(r.author) }} -
|
{% endif %}
|
||||||
{{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
|
{{ user_profile_link(rev.author) }} -
|
||||||
{% endif %}
|
{{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
|
||||||
{% endfor %}
|
</li>
|
||||||
|
{%- endfor -%}
|
||||||
</ul>
|
</ul>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
@ -1,54 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
{{ super() }}
|
|
||||||
<script src="{{ static('com/js/poster_list.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% trans %}Poster{% endtrans %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div id="poster_list">
|
|
||||||
|
|
||||||
<div id="title">
|
|
||||||
<h3>{% trans %}Posters{% endtrans %}</h3>
|
|
||||||
<div id="links" class="right">
|
|
||||||
<a id="create" class="link" href="{{ url(app + ":poster_list") }}">{% trans %}Create{% endtrans %}</a>
|
|
||||||
{% if app == "com" %}
|
|
||||||
<a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="posters">
|
|
||||||
|
|
||||||
{% if poster_list.count() == 0 %}
|
|
||||||
<div id="no-posters">{% trans %}No posters{% endtrans %}</div>
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
{% for poster in poster_list %}
|
|
||||||
<div class="poster">
|
|
||||||
<div class="name">{{ poster.name }}</div>
|
|
||||||
<div class="image"><img src="{{ poster.file.url }}"></img></div>
|
|
||||||
<div class="dates">
|
|
||||||
<div class="begin">{{ poster.date_begin | date("d/M/Y H:m") }}</div>
|
|
||||||
<div class="end">{{ poster.date_end | date("d/M/Y H:m") }}</div>
|
|
||||||
</div>
|
|
||||||
<a class="edit" href="{{ url(poster_edit_url_name, poster.id) }}">{% trans %}Edit{% endtrans %}</a>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="view"><div id="placeholder"></div></div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -244,27 +244,30 @@
|
|||||||
{% block script %}
|
{% block script %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
$(function () {
|
// Image selection
|
||||||
var keys = [];
|
for (const img of document.querySelectorAll("#small_pictures img")){
|
||||||
var pattern = "71,85,89,71,85,89";
|
img.addEventListener("click", (e) => {
|
||||||
$(document).keydown(function (e) {
|
const displayed = document.querySelector("#big_picture img");
|
||||||
keys.push(e.keyCode);
|
displayed.src = e.target.src;
|
||||||
if (keys.toString() == pattern) {
|
displayed.alt = e.target.alt;
|
||||||
keys = [];
|
displayed.title = e.target.title;
|
||||||
$("#big_picture img").attr("src", "{{ static('core/img/yug.jpg') }}");
|
|
||||||
}
|
|
||||||
if (keys.length == 6) {
|
|
||||||
keys.shift();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
$(function () {
|
|
||||||
$("#small_pictures img").click(function () {
|
|
||||||
$("#big_picture img").attr("src", $(this)[0].src);
|
|
||||||
$("#big_picture img").attr("alt", $(this)[0].alt);
|
|
||||||
$("#big_picture img").attr("title", $(this)[0].title);
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys = [];
|
||||||
|
const pattern = "71,85,89,71,85,89";
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
keys.push(e.keyCode);
|
||||||
|
if (keys.toString() === pattern) {
|
||||||
|
keys = [];
|
||||||
|
document.querySelector("#big_picture img").src = "{{ static('core/img/yug.jpg') }}";
|
||||||
|
}
|
||||||
|
if (keys.length === 6) {
|
||||||
|
keys.shift();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
$("#drop_gifts").accordion({
|
$("#drop_gifts").accordion({
|
||||||
heightStyle: "content",
|
heightStyle: "content",
|
||||||
|
@ -63,9 +63,7 @@
|
|||||||
{%- trans -%}Delete{%- endtrans -%}
|
{%- trans -%}Delete{%- endtrans -%}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
{{ form[field_name].label_tag() }}
|
||||||
{{ form[field_name].label }}
|
|
||||||
</p>
|
|
||||||
{{ form[field_name].errors }}
|
{{ form[field_name].errors }}
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
|
<em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
|
||||||
@ -118,68 +116,68 @@
|
|||||||
{# All fields #}
|
{# All fields #}
|
||||||
<div class="profile-fields">
|
<div class="profile-fields">
|
||||||
{%- for field in form -%}
|
{%- for field in form -%}
|
||||||
{%-
|
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
|
||||||
if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"]
|
{%- continue -%}
|
||||||
-%}
|
{%- endif -%}
|
||||||
{%- continue -%}
|
|
||||||
{%- endif -%}
|
|
||||||
|
|
||||||
<div class="profile-field">
|
<div class="profile-field">
|
||||||
<div class="profile-field-label">{{ field.label }}</div>
|
<div class="profile-field-label">{{ field.label }}</div>
|
||||||
<div class="profile-field-content">
|
<div class="profile-field-content">
|
||||||
{{ field }}
|
{{ field }}
|
||||||
{%- if field.errors -%}
|
{%- if field.errors -%}
|
||||||
<div class="field-error">{{ field.errors }}</div>
|
<div class="field-error">{{ field.errors }}</div>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{%- endfor -%}
|
||||||
{%- endfor -%}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Textareas #}
|
{# Textareas #}
|
||||||
<div class="profile-fields">
|
<div class="profile-fields">
|
||||||
{%- for field in [form.quote, form.forum_signature] -%}
|
{%- for field in [form.quote, form.forum_signature] -%}
|
||||||
<div class="profile-field">
|
<div class="profile-field">
|
||||||
<div class="profile-field-label">{{ field.label }}</div>
|
<div class="profile-field-label">{{ field.label }}</div>
|
||||||
<div class="profile-field-content">
|
<div class="profile-field-content">
|
||||||
{{ field }}
|
{{ field }}
|
||||||
{%- if field.errors -%}
|
{%- if field.errors -%}
|
||||||
<div class="field-error">{{ field.errors }}</div>
|
<div class="field-error">{{ field.errors }}</div>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
</div>
|
</div>
|
||||||
{%- endfor -%}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Checkboxes #}
|
{# Checkboxes #}
|
||||||
<div class="profile-visible">
|
<div class="profile-visible">
|
||||||
{{ form.is_subscriber_viewable }}
|
{{ form.is_subscriber_viewable }}
|
||||||
{{ form.is_subscriber_viewable.label }}
|
{{ form.is_subscriber_viewable.label }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="final-actions">
|
||||||
|
|
||||||
|
{%- if form.instance == user -%}
|
||||||
|
<p>
|
||||||
|
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
|
||||||
|
</p>
|
||||||
|
{%- elif user.is_root -%}
|
||||||
|
<p>
|
||||||
|
<a href="{{ url('core:password_root_change', user_id=form.instance.id) }}">
|
||||||
|
{%- trans -%}Change user password{%- endtrans -%}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
{%- if form.instance == user -%}
|
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
|
<em>{%- trans -%}Username: {%- endtrans -%} {{ form.instance.username }}</em>
|
||||||
|
<br />
|
||||||
|
{%- if form.instance.customer -%}
|
||||||
|
<em>{%- trans -%}Account number: {%- endtrans -%} {{ form.instance.customer.account_id }}</em>
|
||||||
|
{%- endif -%}
|
||||||
</p>
|
</p>
|
||||||
{%- elif user.is_root -%}
|
|
||||||
<p>
|
|
||||||
<a href="{{ url('core:password_root_change', user_id=form.instance.id) }}">
|
|
||||||
{%- trans -%}Change user password{%- endtrans -%}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{%- endif -%}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<em>{%- trans -%}Username: {%- endtrans -%} {{ form.instance.username }}</em>
|
|
||||||
<br />
|
|
||||||
{%- if form.instance.customer -%}
|
|
||||||
<em>{%- trans -%}Account number: {%- endtrans -%} {{ form.instance.customer.account_id }}</em>
|
|
||||||
{%- endif -%}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
@ -23,6 +23,9 @@
|
|||||||
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
|
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
|
<li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if user.has_perm("core.view_userban") %}
|
||||||
|
<li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
|
||||||
|
{% endif %}
|
||||||
{% if user.can_create_subscription or user.is_root %}
|
{% if user.can_create_subscription or user.is_root %}
|
||||||
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
|
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -52,7 +55,7 @@
|
|||||||
%}
|
%}
|
||||||
<li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:producttype_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
|
||||||
|
@ -18,6 +18,7 @@ from smtplib import SMTPException
|
|||||||
|
|
||||||
import freezegun
|
import freezegun
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
@ -30,7 +31,7 @@ from model_bakery import baker
|
|||||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||||
|
|
||||||
from antispam.models import ToxicDomain
|
from antispam.models import ToxicDomain
|
||||||
from club.models import Membership
|
from club.models import Club, Membership
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import AnonymousUser, Group, Page, User
|
from core.models import AnonymousUser, Group, Page, User
|
||||||
from core.utils import get_semester_code, get_start_of_semester
|
from core.utils import get_semester_code, get_start_of_semester
|
||||||
@ -118,7 +119,9 @@ class TestUserRegistration:
|
|||||||
response = client.post(reverse("core:register"), valid_payload)
|
response = client.post(reverse("core:register"), valid_payload)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
error_html = "<li>Un objet User avec ce champ Adresse email existe déjà.</li>"
|
error_html = (
|
||||||
|
"<li>Un objet Utilisateur avec ce champ Adresse email existe déjà.</li>"
|
||||||
|
)
|
||||||
assertInHTML(error_html, str(response.content.decode()))
|
assertInHTML(error_html, str(response.content.decode()))
|
||||||
|
|
||||||
def test_register_fail_with_not_existing_email(
|
def test_register_fail_with_not_existing_email(
|
||||||
@ -143,7 +146,7 @@ class TestUserRegistration:
|
|||||||
class TestUserLogin:
|
class TestUserLogin:
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def user(self) -> User:
|
def user(self) -> User:
|
||||||
return User.objects.first()
|
return baker.make(User, password=make_password("plop"))
|
||||||
|
|
||||||
def test_login_fail(self, client, user):
|
def test_login_fail(self, client, user):
|
||||||
"""Should not login a user correctly."""
|
"""Should not login a user correctly."""
|
||||||
@ -347,56 +350,35 @@ class TestUserIsInGroup(TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
from club.models import Club
|
|
||||||
|
|
||||||
cls.root_group = Group.objects.get(name="Root")
|
cls.root_group = Group.objects.get(name="Root")
|
||||||
cls.public = Group.objects.get(name="Public")
|
cls.public_group = Group.objects.get(name="Public")
|
||||||
cls.skia = User.objects.get(username="skia")
|
cls.public_user = baker.make(User)
|
||||||
cls.toto = User.objects.create(
|
|
||||||
username="toto", first_name="a", last_name="b", email="a.b@toto.fr"
|
|
||||||
)
|
|
||||||
cls.subscribers = Group.objects.get(name="Subscribers")
|
cls.subscribers = Group.objects.get(name="Subscribers")
|
||||||
cls.old_subscribers = Group.objects.get(name="Old subscribers")
|
cls.old_subscribers = Group.objects.get(name="Old subscribers")
|
||||||
cls.accounting_admin = Group.objects.get(name="Accounting admin")
|
cls.accounting_admin = Group.objects.get(name="Accounting admin")
|
||||||
cls.com_admin = Group.objects.get(name="Communication admin")
|
cls.com_admin = Group.objects.get(name="Communication admin")
|
||||||
cls.counter_admin = Group.objects.get(name="Counter admin")
|
cls.counter_admin = Group.objects.get(name="Counter admin")
|
||||||
cls.banned_alcohol = Group.objects.get(name="Banned from buying alcohol")
|
|
||||||
cls.banned_counters = Group.objects.get(name="Banned from counters")
|
|
||||||
cls.banned_subscription = Group.objects.get(name="Banned to subscribe")
|
|
||||||
cls.sas_admin = Group.objects.get(name="SAS admin")
|
cls.sas_admin = Group.objects.get(name="SAS admin")
|
||||||
cls.club = Club.objects.create(
|
cls.club = baker.make(Club)
|
||||||
name="Fake Club",
|
|
||||||
unix_name="fake-club",
|
|
||||||
address="Fake address",
|
|
||||||
)
|
|
||||||
cls.main_club = Club.objects.get(id=1)
|
cls.main_club = Club.objects.get(id=1)
|
||||||
|
|
||||||
def assert_in_public_group(self, user):
|
def assert_in_public_group(self, user):
|
||||||
assert user.is_in_group(pk=self.public.id)
|
assert user.is_in_group(pk=self.public_group.id)
|
||||||
assert user.is_in_group(name=self.public.name)
|
assert user.is_in_group(name=self.public_group.name)
|
||||||
|
|
||||||
def assert_in_club_metagroups(self, user, club):
|
|
||||||
meta_groups_board = club.unix_name + settings.SITH_BOARD_SUFFIX
|
|
||||||
meta_groups_members = club.unix_name + settings.SITH_MEMBER_SUFFIX
|
|
||||||
assert user.is_in_group(name=meta_groups_board) is False
|
|
||||||
assert user.is_in_group(name=meta_groups_members) is False
|
|
||||||
|
|
||||||
def assert_only_in_public_group(self, user):
|
def assert_only_in_public_group(self, user):
|
||||||
self.assert_in_public_group(user)
|
self.assert_in_public_group(user)
|
||||||
for group in (
|
for group in (
|
||||||
self.root_group,
|
self.root_group,
|
||||||
self.banned_counters,
|
|
||||||
self.accounting_admin,
|
self.accounting_admin,
|
||||||
self.sas_admin,
|
self.sas_admin,
|
||||||
self.subscribers,
|
self.subscribers,
|
||||||
self.old_subscribers,
|
self.old_subscribers,
|
||||||
|
self.club.members_group,
|
||||||
|
self.club.board_group,
|
||||||
):
|
):
|
||||||
assert not user.is_in_group(pk=group.pk)
|
assert not user.is_in_group(pk=group.pk)
|
||||||
assert not user.is_in_group(name=group.name)
|
assert not user.is_in_group(name=group.name)
|
||||||
meta_groups_board = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
|
||||||
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
|
||||||
assert user.is_in_group(name=meta_groups_board) is False
|
|
||||||
assert user.is_in_group(name=meta_groups_members) is False
|
|
||||||
|
|
||||||
def test_anonymous_user(self):
|
def test_anonymous_user(self):
|
||||||
"""Test that anonymous users are only in the public group."""
|
"""Test that anonymous users are only in the public group."""
|
||||||
@ -405,80 +387,80 @@ class TestUserIsInGroup(TestCase):
|
|||||||
|
|
||||||
def test_not_subscribed_user(self):
|
def test_not_subscribed_user(self):
|
||||||
"""Test that users who never subscribed are only in the public group."""
|
"""Test that users who never subscribed are only in the public group."""
|
||||||
self.assert_only_in_public_group(self.toto)
|
self.assert_only_in_public_group(self.public_user)
|
||||||
|
|
||||||
def test_wrong_parameter_fail(self):
|
def test_wrong_parameter_fail(self):
|
||||||
"""Test that when neither the pk nor the name argument is given,
|
"""Test that when neither the pk nor the name argument is given,
|
||||||
the function raises a ValueError.
|
the function raises a ValueError.
|
||||||
"""
|
"""
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
self.toto.is_in_group()
|
self.public_user.is_in_group()
|
||||||
|
|
||||||
def test_number_queries(self):
|
def test_number_queries(self):
|
||||||
"""Test that the number of db queries is stable
|
"""Test that the number of db queries is stable
|
||||||
and that less queries are made when making a new call.
|
and that less queries are made when making a new call.
|
||||||
"""
|
"""
|
||||||
# make sure Skia is in at least one group
|
# make sure Skia is in at least one group
|
||||||
self.skia.groups.add(Group.objects.first().pk)
|
group_in = baker.make(Group)
|
||||||
skia_groups = self.skia.groups.all()
|
self.public_user.groups.add(group_in)
|
||||||
|
|
||||||
group_in = skia_groups.first()
|
|
||||||
cache.clear()
|
cache.clear()
|
||||||
# Test when the user is in the group
|
# Test when the user is in the group
|
||||||
with self.assertNumQueries(2):
|
with self.assertNumQueries(2):
|
||||||
self.skia.is_in_group(pk=group_in.id)
|
self.public_user.is_in_group(pk=group_in.id)
|
||||||
with self.assertNumQueries(0):
|
with self.assertNumQueries(0):
|
||||||
self.skia.is_in_group(pk=group_in.id)
|
self.public_user.is_in_group(pk=group_in.id)
|
||||||
|
|
||||||
ids = skia_groups.values_list("pk", flat=True)
|
group_not_in = baker.make(Group)
|
||||||
group_not_in = Group.objects.exclude(pk__in=ids).first()
|
|
||||||
cache.clear()
|
cache.clear()
|
||||||
# Test when the user is not in the group
|
# Test when the user is not in the group
|
||||||
with self.assertNumQueries(2):
|
with self.assertNumQueries(2):
|
||||||
self.skia.is_in_group(pk=group_not_in.id)
|
self.public_user.is_in_group(pk=group_not_in.id)
|
||||||
with self.assertNumQueries(0):
|
with self.assertNumQueries(0):
|
||||||
self.skia.is_in_group(pk=group_not_in.id)
|
self.public_user.is_in_group(pk=group_not_in.id)
|
||||||
|
|
||||||
def test_cache_properly_cleared_membership(self):
|
def test_cache_properly_cleared_membership(self):
|
||||||
"""Test that when the membership of a user end,
|
"""Test that when the membership of a user end,
|
||||||
the cache is properly invalidated.
|
the cache is properly invalidated.
|
||||||
"""
|
"""
|
||||||
membership = Membership.objects.create(
|
membership = baker.make(Membership, club=self.club, user=self.public_user)
|
||||||
club=self.club, user=self.toto, end_date=None
|
|
||||||
)
|
|
||||||
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
|
||||||
cache.clear()
|
cache.clear()
|
||||||
assert self.toto.is_in_group(name=meta_groups_members) is True
|
self.club.get_membership_for(self.public_user) # this should populate the cache
|
||||||
assert membership == cache.get(f"membership_{self.club.id}_{self.toto.id}")
|
assert membership == cache.get(
|
||||||
|
f"membership_{self.club.id}_{self.public_user.id}"
|
||||||
|
)
|
||||||
membership.end_date = now() - timedelta(minutes=5)
|
membership.end_date = now() - timedelta(minutes=5)
|
||||||
membership.save()
|
membership.save()
|
||||||
cached_membership = cache.get(f"membership_{self.club.id}_{self.toto.id}")
|
cached_membership = cache.get(
|
||||||
|
f"membership_{self.club.id}_{self.public_user.id}"
|
||||||
|
)
|
||||||
assert cached_membership == "not_member"
|
assert cached_membership == "not_member"
|
||||||
assert self.toto.is_in_group(name=meta_groups_members) is False
|
|
||||||
|
|
||||||
def test_cache_properly_cleared_group(self):
|
def test_cache_properly_cleared_group(self):
|
||||||
"""Test that when a user is removed from a group,
|
"""Test that when a user is removed from a group,
|
||||||
the is_in_group_method return False when calling it again.
|
the is_in_group_method return False when calling it again.
|
||||||
"""
|
"""
|
||||||
# testing with pk
|
# testing with pk
|
||||||
self.toto.groups.add(self.com_admin.pk)
|
self.public_user.groups.add(self.com_admin.pk)
|
||||||
assert self.toto.is_in_group(pk=self.com_admin.pk) is True
|
assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
|
||||||
|
|
||||||
self.toto.groups.remove(self.com_admin.pk)
|
self.public_user.groups.remove(self.com_admin.pk)
|
||||||
assert self.toto.is_in_group(pk=self.com_admin.pk) is False
|
assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
|
||||||
|
|
||||||
# testing with name
|
# testing with name
|
||||||
self.toto.groups.add(self.sas_admin.pk)
|
self.public_user.groups.add(self.sas_admin.pk)
|
||||||
assert self.toto.is_in_group(name="SAS admin") is True
|
assert self.public_user.is_in_group(name="SAS admin") is True
|
||||||
|
|
||||||
self.toto.groups.remove(self.sas_admin.pk)
|
self.public_user.groups.remove(self.sas_admin.pk)
|
||||||
assert self.toto.is_in_group(name="SAS admin") is False
|
assert self.public_user.is_in_group(name="SAS admin") is False
|
||||||
|
|
||||||
def test_not_existing_group(self):
|
def test_not_existing_group(self):
|
||||||
"""Test that searching for a not existing group
|
"""Test that searching for a not existing group
|
||||||
returns False.
|
returns False.
|
||||||
"""
|
"""
|
||||||
assert self.skia.is_in_group(name="This doesn't exist") is False
|
user = baker.make(User)
|
||||||
|
user.groups.set(list(Group.objects.all()))
|
||||||
|
assert not user.is_in_group(name="This doesn't exist")
|
||||||
|
|
||||||
|
|
||||||
class TestDateUtils(TestCase):
|
class TestDateUtils(TestCase):
|
||||||
|
@ -14,7 +14,7 @@ from PIL import Image
|
|||||||
from pytest_django.asserts import assertNumQueries
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, RealGroup, SithFile, User
|
from core.models import Group, SithFile, User
|
||||||
from sas.models import Picture
|
from sas.models import Picture
|
||||||
from sith import settings
|
from sith import settings
|
||||||
|
|
||||||
@ -26,12 +26,10 @@ class TestImageAccess:
|
|||||||
[
|
[
|
||||||
lambda: baker.make(User, is_superuser=True),
|
lambda: baker.make(User, is_superuser=True),
|
||||||
lambda: baker.make(
|
lambda: baker.make(
|
||||||
User,
|
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||||
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
|
|
||||||
),
|
),
|
||||||
lambda: baker.make(
|
lambda: baker.make(
|
||||||
User,
|
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
|
||||||
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -2,7 +2,6 @@ from datetime import timedelta
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -10,9 +9,7 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker, seq
|
from model_bakery import baker, seq
|
||||||
from model_bakery.recipe import Recipe, foreign_key
|
from model_bakery.recipe import Recipe, foreign_key
|
||||||
|
|
||||||
from club.models import Club, Membership
|
|
||||||
from core.baker_recipes import (
|
from core.baker_recipes import (
|
||||||
board_user,
|
|
||||||
old_subscriber_user,
|
old_subscriber_user,
|
||||||
subscriber_user,
|
subscriber_user,
|
||||||
very_old_subscriber_user,
|
very_old_subscriber_user,
|
||||||
@ -20,7 +17,6 @@ from core.baker_recipes import (
|
|||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.models import Counter, Refilling, Selling
|
from counter.models import Counter, Refilling, Selling
|
||||||
from eboutic.models import Invoice, InvoiceItem
|
from eboutic.models import Invoice, InvoiceItem
|
||||||
from trombi.models import Trombi, TrombiUser
|
|
||||||
|
|
||||||
|
|
||||||
class TestSearchUsers(TestCase):
|
class TestSearchUsers(TestCase):
|
||||||
@ -191,103 +187,3 @@ def test_generate_username(first_name: str, last_name: str, expected: str):
|
|||||||
new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
|
new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
|
||||||
new_user.generate_username()
|
new_user.generate_username()
|
||||||
assert new_user.username == expected
|
assert new_user.username == expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestUserPreferences:
|
|
||||||
@pytest.fixture
|
|
||||||
def subscriber(self) -> User:
|
|
||||||
return subscriber_user.make()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def other_subscriber(self) -> User:
|
|
||||||
return subscriber_user.make()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def trombi_user(self) -> User:
|
|
||||||
user = subscriber_user.make()
|
|
||||||
club = baker.make(Club)
|
|
||||||
baker.make(
|
|
||||||
Membership,
|
|
||||||
club=club,
|
|
||||||
start_date=now() - timedelta(days=30),
|
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Curious"],
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
trombi = baker.make(Trombi, club=club)
|
|
||||||
baker.make(TrombiUser, user=user, trombi=trombi)
|
|
||||||
return user
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def non_subscriber(self) -> User:
|
|
||||||
return baker.make(User)
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def club_admin(self) -> User:
|
|
||||||
user = baker.make(User)
|
|
||||||
baker.make(
|
|
||||||
Membership,
|
|
||||||
start_date=now() - timedelta(days=30),
|
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def board_member(self) -> User:
|
|
||||||
return board_user.make()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def admin(self) -> User:
|
|
||||||
return baker.make(User, is_superuser=True)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("tested_user", "accessing_user", "expected_code"),
|
|
||||||
[
|
|
||||||
("subscriber", None, 403), # Anonymous user
|
|
||||||
("subscriber", "non_subscriber", 403),
|
|
||||||
("subscriber", "club_admin", 403),
|
|
||||||
("subscriber", "other_subscriber", 403),
|
|
||||||
("subscriber", "trombi_user", 403),
|
|
||||||
("subscriber", "subscriber", 200),
|
|
||||||
("subscriber", "board_member", 200),
|
|
||||||
("subscriber", "admin", 200),
|
|
||||||
("non_subscriber", None, 403), # Anonymous user
|
|
||||||
("non_subscriber", "club_admin", 403),
|
|
||||||
("non_subscriber", "subscriber", 403),
|
|
||||||
("non_subscriber", "other_subscriber", 403),
|
|
||||||
("non_subscriber", "trombi_user", 403),
|
|
||||||
("non_subscriber", "non_subscriber", 200),
|
|
||||||
("non_subscriber", "board_member", 200),
|
|
||||||
("non_subscriber", "admin", 200),
|
|
||||||
("trombi_user", None, 403), # Anonymous user
|
|
||||||
("trombi_user", "club_admin", 403),
|
|
||||||
("trombi_user", "subscriber", 403),
|
|
||||||
("trombi_user", "other_subscriber", 403),
|
|
||||||
("trombi_user", "non_subscriber", 403),
|
|
||||||
("trombi_user", "trombi_user", 200),
|
|
||||||
("trombi_user", "board_member", 200),
|
|
||||||
("trombi_user", "admin", 200),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_user_preferences_access(
|
|
||||||
self,
|
|
||||||
client: Client,
|
|
||||||
request: pytest.FixtureRequest,
|
|
||||||
tested_user: str,
|
|
||||||
accessing_user: str | None,
|
|
||||||
expected_code: int,
|
|
||||||
):
|
|
||||||
cache.clear()
|
|
||||||
if accessing_user is not None:
|
|
||||||
client.force_login(request.getfixturevalue(accessing_user))
|
|
||||||
assert (
|
|
||||||
client.get(
|
|
||||||
reverse(
|
|
||||||
"core:user_prefs",
|
|
||||||
kwargs={"user_id": request.getfixturevalue(tested_user).pk},
|
|
||||||
)
|
|
||||||
).status_code
|
|
||||||
== expected_code
|
|
||||||
)
|
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote, urljoin
|
from urllib.parse import quote, urljoin
|
||||||
|
|
||||||
# This file contains all the views that concern the page model
|
# This file contains all the views that concern the page model
|
||||||
@ -21,6 +22,7 @@ from wsgiref.util import FileWrapper
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.db.models import Exists, OuterRef
|
||||||
from django.forms.models import modelform_factory
|
from django.forms.models import modelform_factory
|
||||||
from django.http import Http404, HttpRequest, HttpResponse
|
from django.http import Http404, HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
@ -31,7 +33,7 @@ from django.views.generic import DetailView, ListView
|
|||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
|
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
|
||||||
|
|
||||||
from core.models import Notification, RealGroup, SithFile, User
|
from core.models import Notification, SithFile, User
|
||||||
from core.views import (
|
from core.views import (
|
||||||
AllowFragment,
|
AllowFragment,
|
||||||
CanEditMixin,
|
CanEditMixin,
|
||||||
@ -47,6 +49,41 @@ from core.views.widgets.select import (
|
|||||||
from counter.utils import is_logged_in_counter
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
|
def send_raw_file(path: Path) -> HttpResponse:
|
||||||
|
"""Send a file located in the MEDIA_ROOT
|
||||||
|
|
||||||
|
This handles all the logic of using production reverse proxy or debug server.
|
||||||
|
|
||||||
|
THIS DOESN'T CHECK ANY PERMISSIONS !
|
||||||
|
"""
|
||||||
|
if not path.is_relative_to(settings.MEDIA_ROOT):
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
if not path.is_file() or not path.exists():
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
response = HttpResponse(
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{quote(path.name)}"'}
|
||||||
|
)
|
||||||
|
if not settings.DEBUG:
|
||||||
|
# When receiving a response with the Accel-Redirect header,
|
||||||
|
# the reverse proxy will automatically handle the file sending.
|
||||||
|
# This is really hard to test (thus isn't tested)
|
||||||
|
# so please do not mess with this.
|
||||||
|
response["Content-Type"] = "" # automatically set by nginx
|
||||||
|
response["X-Accel-Redirect"] = quote(
|
||||||
|
urljoin(settings.MEDIA_URL, str(path.relative_to(settings.MEDIA_ROOT)))
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
with open(path, "rb") as filename:
|
||||||
|
response.content = FileWrapper(filename)
|
||||||
|
response["Content-Type"] = mimetypes.guess_type(path)[0]
|
||||||
|
response["Last-Modified"] = http_date(path.stat().st_mtime)
|
||||||
|
response["Content-Length"] = path.stat().st_size
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def send_file(
|
def send_file(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
file_id: int,
|
file_id: int,
|
||||||
@ -65,28 +102,7 @@ def send_file(
|
|||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
name = getattr(f, file_attr).name
|
name = getattr(f, file_attr).name
|
||||||
|
|
||||||
response = HttpResponse(
|
return send_raw_file(settings.MEDIA_ROOT / name)
|
||||||
headers={"Content-Disposition": f'inline; filename="{quote(name)}"'}
|
|
||||||
)
|
|
||||||
if not settings.DEBUG:
|
|
||||||
# When receiving a response with the Accel-Redirect header,
|
|
||||||
# the reverse proxy will automatically handle the file sending.
|
|
||||||
# This is really hard to test (thus isn't tested)
|
|
||||||
# so please do not mess with this.
|
|
||||||
response["Content-Type"] = "" # automatically set by nginx
|
|
||||||
response["X-Accel-Redirect"] = quote(urljoin(settings.MEDIA_URL, name))
|
|
||||||
return response
|
|
||||||
|
|
||||||
filepath = settings.MEDIA_ROOT / name
|
|
||||||
# check if file exists on disk
|
|
||||||
if not filepath.exists():
|
|
||||||
raise Http404
|
|
||||||
with open(filepath, "rb") as filename:
|
|
||||||
response.content = FileWrapper(filename)
|
|
||||||
response["Content-Type"] = mimetypes.guess_type(filepath)[0]
|
|
||||||
response["Last-Modified"] = http_date(f.date.timestamp())
|
|
||||||
response["Content-Length"] = filepath.stat().st_size
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class MultipleFileInput(forms.ClearableFileInput):
|
class MultipleFileInput(forms.ClearableFileInput):
|
||||||
@ -159,19 +175,18 @@ class AddFilesForm(forms.Form):
|
|||||||
% {"file_name": f, "msg": repr(e)},
|
% {"file_name": f, "msg": repr(e)},
|
||||||
)
|
)
|
||||||
if notif:
|
if notif:
|
||||||
for u in (
|
unread_notif_subquery = Notification.objects.filter(
|
||||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
|
||||||
.first()
|
)
|
||||||
.users.all()
|
for user in User.objects.filter(
|
||||||
|
~Exists(unread_notif_subquery),
|
||||||
|
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||||
):
|
):
|
||||||
if not u.notifications.filter(
|
Notification.objects.create(
|
||||||
type="FILE_MODERATION", viewed=False
|
user=user,
|
||||||
).exists():
|
url=reverse("core:file_moderation"),
|
||||||
Notification(
|
type="FILE_MODERATION",
|
||||||
user=u,
|
)
|
||||||
url=reverse("core:file_moderation"),
|
|
||||||
type="FILE_MODERATION",
|
|
||||||
).save()
|
|
||||||
|
|
||||||
|
|
||||||
class FileListView(ListView):
|
class FileListView(ListView):
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
import re
|
import re
|
||||||
|
from datetime import date, datetime
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from captcha.fields import CaptchaField
|
from captcha.fields import CaptchaField
|
||||||
@ -37,14 +38,16 @@ from django.forms import (
|
|||||||
DateInput,
|
DateInput,
|
||||||
DateTimeInput,
|
DateTimeInput,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Widget,
|
||||||
)
|
)
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from antispam.forms import AntiSpamEmailField
|
from antispam.forms import AntiSpamEmailField
|
||||||
from core.models import Gift, Page, SithFile, User
|
from core.models import Gift, Group, Page, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
from core.views.widgets.select import (
|
from core.views.widgets.select import (
|
||||||
AutoCompleteSelect,
|
AutoCompleteSelect,
|
||||||
@ -130,6 +133,23 @@ class SelectUser(TextInput):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
# Fields
|
||||||
|
|
||||||
|
|
||||||
|
def validate_future_timestamp(value: date | datetime):
|
||||||
|
if value <= now():
|
||||||
|
raise ValueError(_("Ensure this timestamp is set in the future"))
|
||||||
|
|
||||||
|
|
||||||
|
class FutureDateTimeField(forms.DateTimeField):
|
||||||
|
"""A datetime field that accepts only future timestamps."""
|
||||||
|
|
||||||
|
default_validators = [validate_future_timestamp]
|
||||||
|
|
||||||
|
def widget_attrs(self, widget: Widget) -> dict[str, str]:
|
||||||
|
return {"min": widget.format_value(now())}
|
||||||
|
|
||||||
|
|
||||||
# Forms
|
# Forms
|
||||||
|
|
||||||
|
|
||||||
@ -167,14 +187,15 @@ class RegisteringForm(UserCreationForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ("first_name", "last_name", "email")
|
fields = ("first_name", "last_name", "email")
|
||||||
field_classes = {
|
field_classes = {"email": AntiSpamEmailField}
|
||||||
"email": AntiSpamEmailField,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(forms.ModelForm):
|
class UserProfileForm(forms.ModelForm):
|
||||||
"""Form handling the user profile, managing the files"""
|
"""Form handling the user profile, managing the files"""
|
||||||
|
|
||||||
|
required_css_class = "required"
|
||||||
|
error_css_class = "error"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
@ -287,15 +308,20 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
self._post_clean()
|
self._post_clean()
|
||||||
|
|
||||||
|
|
||||||
class UserPropForm(forms.ModelForm):
|
class UserGroupsForm(forms.ModelForm):
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
|
|
||||||
|
groups = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Group.objects.filter(is_manually_manageable=True),
|
||||||
|
widget=CheckboxSelectMultiple,
|
||||||
|
label=_("Groups"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ["groups"]
|
fields = ["groups"]
|
||||||
help_texts = {"groups": "Which groups this user belongs to"}
|
|
||||||
widgets = {"groups": CheckboxSelectMultiple}
|
|
||||||
|
|
||||||
|
|
||||||
class UserGodfathersForm(forms.Form):
|
class UserGodfathersForm(forms.Form):
|
||||||
|
@ -21,11 +21,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||||
|
|
||||||
from core.models import RealGroup, User
|
from core.models import Group, User
|
||||||
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
|
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
|
||||||
from core.views.widgets.select import (
|
from core.views.widgets.select import AutoCompleteSelectMultipleUser
|
||||||
AutoCompleteSelectMultipleUser,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Forms
|
# Forms
|
||||||
|
|
||||||
@ -59,7 +57,8 @@ class EditMembersForm(forms.Form):
|
|||||||
class GroupListView(CanEditMixin, ListView):
|
class GroupListView(CanEditMixin, ListView):
|
||||||
"""Displays the Group list."""
|
"""Displays the Group list."""
|
||||||
|
|
||||||
model = RealGroup
|
model = Group
|
||||||
|
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
template_name = "core/group_list.jinja"
|
template_name = "core/group_list.jinja"
|
||||||
|
|
||||||
@ -67,7 +66,8 @@ class GroupListView(CanEditMixin, ListView):
|
|||||||
class GroupEditView(CanEditMixin, UpdateView):
|
class GroupEditView(CanEditMixin, UpdateView):
|
||||||
"""Edit infos of a Group."""
|
"""Edit infos of a Group."""
|
||||||
|
|
||||||
model = RealGroup
|
model = Group
|
||||||
|
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||||
pk_url_kwarg = "group_id"
|
pk_url_kwarg = "group_id"
|
||||||
template_name = "core/group_edit.jinja"
|
template_name = "core/group_edit.jinja"
|
||||||
fields = ["name", "description"]
|
fields = ["name", "description"]
|
||||||
@ -76,7 +76,8 @@ class GroupEditView(CanEditMixin, UpdateView):
|
|||||||
class GroupCreateView(CanCreateMixin, CreateView):
|
class GroupCreateView(CanCreateMixin, CreateView):
|
||||||
"""Add a new Group."""
|
"""Add a new Group."""
|
||||||
|
|
||||||
model = RealGroup
|
model = Group
|
||||||
|
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
fields = ["name", "description"]
|
fields = ["name", "description"]
|
||||||
|
|
||||||
@ -86,7 +87,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
|||||||
Allow adding and removing users from it.
|
Allow adding and removing users from it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = RealGroup
|
model = Group
|
||||||
|
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||||
form_class = EditMembersForm
|
form_class = EditMembersForm
|
||||||
pk_url_kwarg = "group_id"
|
pk_url_kwarg = "group_id"
|
||||||
template_name = "core/group_detail.jinja"
|
template_name = "core/group_detail.jinja"
|
||||||
@ -120,7 +122,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
|||||||
class GroupDeleteView(CanEditMixin, DeleteView):
|
class GroupDeleteView(CanEditMixin, DeleteView):
|
||||||
"""Delete a Group."""
|
"""Delete a Group."""
|
||||||
|
|
||||||
model = RealGroup
|
model = Group
|
||||||
|
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||||
pk_url_kwarg = "group_id"
|
pk_url_kwarg = "group_id"
|
||||||
template_name = "core/delete_confirm.jinja"
|
template_name = "core/delete_confirm.jinja"
|
||||||
success_url = reverse_lazy("core:group_list")
|
success_url = reverse_lazy("core:group_list")
|
||||||
|
@ -64,16 +64,20 @@ class PageView(CanViewMixin, DetailView):
|
|||||||
class PageHistView(CanViewMixin, DetailView):
|
class PageHistView(CanViewMixin, DetailView):
|
||||||
model = Page
|
model = Page
|
||||||
template_name = "core/page_hist.jinja"
|
template_name = "core/page_hist.jinja"
|
||||||
|
slug_field = "_full_name"
|
||||||
|
slug_url_kwarg = "page_name"
|
||||||
|
_cached_object: Page | None = None
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
res = super().dispatch(request, *args, **kwargs)
|
page = self.get_object()
|
||||||
if self.object.need_club_redirection:
|
if page.need_club_redirection:
|
||||||
return redirect("club:club_hist", club_id=self.object.club.id)
|
return redirect("club:club_hist", club_id=page.club.id)
|
||||||
return res
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self, *args, **kwargs):
|
||||||
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
|
if not self._cached_object:
|
||||||
return self.page
|
self._cached_object = super().get_object()
|
||||||
|
return self._cached_object
|
||||||
|
|
||||||
|
|
||||||
class PageRevView(CanViewMixin, DetailView):
|
class PageRevView(CanViewMixin, DetailView):
|
||||||
|
@ -35,7 +35,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db.models import DateField, QuerySet
|
from django.db.models import DateField, QuerySet
|
||||||
from django.db.models.functions import Trunc
|
from django.db.models.functions import Trunc
|
||||||
from django.forms import CheckboxSelectMultiple
|
|
||||||
from django.forms.models import modelform_factory
|
from django.forms.models import modelform_factory
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
@ -68,6 +67,7 @@ from core.views.forms import (
|
|||||||
LoginForm,
|
LoginForm,
|
||||||
RegisteringForm,
|
RegisteringForm,
|
||||||
UserGodfathersForm,
|
UserGodfathersForm,
|
||||||
|
UserGroupsForm,
|
||||||
UserProfileForm,
|
UserProfileForm,
|
||||||
)
|
)
|
||||||
from counter.models import Refilling, Selling
|
from counter.models import Refilling, Selling
|
||||||
@ -583,9 +583,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
|
|||||||
model = User
|
model = User
|
||||||
pk_url_kwarg = "user_id"
|
pk_url_kwarg = "user_id"
|
||||||
template_name = "core/user_group.jinja"
|
template_name = "core/user_group.jinja"
|
||||||
form_class = modelform_factory(
|
form_class = UserGroupsForm
|
||||||
User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple}
|
|
||||||
)
|
|
||||||
context_object_name = "profile"
|
context_object_name = "profile"
|
||||||
current_tab = "groups"
|
current_tab = "groups"
|
||||||
|
|
||||||
|
@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
|
|||||||
|
|
||||||
@admin.register(ProductType)
|
@admin.register(ProductType)
|
||||||
class ProductTypeAdmin(admin.ModelAdmin):
|
class ProductTypeAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "priority")
|
list_display = ("name", "order")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CashRegisterSummary)
|
@admin.register(CashRegisterSummary)
|
||||||
|
@ -12,24 +12,33 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from typing import Annotated
|
from django.conf import settings
|
||||||
|
from django.db.models import F
|
||||||
from annotated_types import MinLen
|
from django.shortcuts import get_object_or_404
|
||||||
from django.db.models import Q
|
|
||||||
from ninja import Query
|
from ninja import Query
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from core.api_permissions import CanAccessLookup, CanView, IsRoot
|
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||||
from counter.models import Counter, Product
|
from counter.models import Counter, Product, ProductType
|
||||||
from counter.schemas import (
|
from counter.schemas import (
|
||||||
CounterFilterSchema,
|
CounterFilterSchema,
|
||||||
CounterSchema,
|
CounterSchema,
|
||||||
|
ProductFilterSchema,
|
||||||
ProductSchema,
|
ProductSchema,
|
||||||
|
ProductTypeSchema,
|
||||||
|
ReorderProductTypeSchema,
|
||||||
|
SimpleProductSchema,
|
||||||
SimplifiedCounterSchema,
|
SimplifiedCounterSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
IsCounterAdmin = (
|
||||||
|
IsRoot
|
||||||
|
| IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||||
|
| IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/counter")
|
@api_controller("/counter")
|
||||||
class CounterController(ControllerBase):
|
class CounterController(ControllerBase):
|
||||||
@ -64,15 +73,72 @@ class CounterController(ControllerBase):
|
|||||||
class ProductController(ControllerBase):
|
class ProductController(ControllerBase):
|
||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[ProductSchema],
|
response=PaginatedResponseSchema[SimpleProductSchema],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_products(self, search: Annotated[str, MinLen(1)]):
|
def search_products(self, filters: Query[ProductFilterSchema]):
|
||||||
return (
|
return filters.filter(
|
||||||
Product.objects.filter(
|
Product.objects.order_by(
|
||||||
Q(name__icontains=search) | Q(code__icontains=search)
|
F("product_type__order").asc(nulls_last=True),
|
||||||
)
|
"product_type",
|
||||||
.filter(archived=False)
|
"name",
|
||||||
.values()
|
).values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@route.get(
|
||||||
|
"/search/detailed",
|
||||||
|
response=PaginatedResponseSchema[ProductSchema],
|
||||||
|
permissions=[IsCounterAdmin],
|
||||||
|
url_name="search_products_detailed",
|
||||||
|
)
|
||||||
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
def search_products_detailed(self, filters: Query[ProductFilterSchema]):
|
||||||
|
"""Get the detailed information about the products."""
|
||||||
|
return filters.filter(
|
||||||
|
Product.objects.select_related("club")
|
||||||
|
.prefetch_related("buying_groups")
|
||||||
|
.select_related("product_type")
|
||||||
|
.order_by(
|
||||||
|
F("product_type__order").asc(nulls_last=True),
|
||||||
|
"product_type",
|
||||||
|
"name",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/product-type", permissions=[IsCounterAdmin])
|
||||||
|
class ProductTypeController(ControllerBase):
|
||||||
|
@route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types")
|
||||||
|
def fetch_all(self):
|
||||||
|
return ProductType.objects.order_by("order")
|
||||||
|
|
||||||
|
@route.patch("/{type_id}/move")
|
||||||
|
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
|
||||||
|
"""Change the order of a product type.
|
||||||
|
|
||||||
|
To use this route, give either the id of the product type
|
||||||
|
this one should be above of,
|
||||||
|
of the id of the product type this one should be below of.
|
||||||
|
|
||||||
|
Order affects the display order of the product types.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```
|
||||||
|
GET /api/counter/product-type
|
||||||
|
=> [<1: type A>, <2: type B>, <3: type C>]
|
||||||
|
|
||||||
|
PATCH /api/counter/product-type/3/move?below=1
|
||||||
|
|
||||||
|
GET /api/counter/product-type
|
||||||
|
=> [<1: type A>, <3: type C>, <2: type B>]
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
product_type: ProductType = self.get_object_or_exception(
|
||||||
|
ProductType, pk=type_id
|
||||||
|
)
|
||||||
|
other = get_object_or_404(ProductType, pk=other_id.above or other_id.below)
|
||||||
|
if other_id.below is not None:
|
||||||
|
product_type.below(other)
|
||||||
|
else:
|
||||||
|
product_type.above(other)
|
||||||
|
@ -24,6 +24,12 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
PAYMENT_METHOD = [
|
||||||
|
("CHECK", _("Check")),
|
||||||
|
("CASH", _("Cash")),
|
||||||
|
("CARD", _("Credit card")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CounterConfig(AppConfig):
|
class CounterConfig(AppConfig):
|
||||||
name = "counter"
|
name = "counter"
|
||||||
|
@ -47,6 +47,8 @@ class BillingInfoForm(forms.ModelForm):
|
|||||||
class StudentCardForm(forms.ModelForm):
|
class StudentCardForm(forms.ModelForm):
|
||||||
"""Form for adding student cards"""
|
"""Form for adding student cards"""
|
||||||
|
|
||||||
|
error_css_class = "error"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StudentCard
|
model = StudentCard
|
||||||
fields = ["uid"]
|
fields = ["uid"]
|
||||||
@ -87,7 +89,7 @@ class GetUserForm(forms.Form):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
cus = None
|
customer = None
|
||||||
if cleaned_data["code"] != "":
|
if cleaned_data["code"] != "":
|
||||||
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
|
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
|
||||||
card = (
|
card = (
|
||||||
@ -96,21 +98,24 @@ class GetUserForm(forms.Form):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if card is not None:
|
if card is not None:
|
||||||
cus = card.customer
|
customer = card.customer
|
||||||
if cus is None:
|
if customer is None:
|
||||||
cus = Customer.objects.filter(
|
customer = Customer.objects.filter(
|
||||||
account_id__iexact=cleaned_data["code"]
|
account_id__iexact=cleaned_data["code"]
|
||||||
).first()
|
).first()
|
||||||
elif cleaned_data["id"] is not None:
|
elif cleaned_data["id"]:
|
||||||
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
|
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||||
if cus is None or not cus.can_buy:
|
|
||||||
|
if customer is None or not customer.can_buy:
|
||||||
raise forms.ValidationError(_("User not found"))
|
raise forms.ValidationError(_("User not found"))
|
||||||
cleaned_data["user_id"] = cus.user.id
|
cleaned_data["user_id"] = customer.user.id
|
||||||
cleaned_data["user"] = cus.user
|
cleaned_data["user"] = customer.user
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class RefillForm(forms.ModelForm):
|
class RefillForm(forms.ModelForm):
|
||||||
|
allowed_refilling_methods = ["CASH", "CARD"]
|
||||||
|
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
amount = forms.FloatField(
|
amount = forms.FloatField(
|
||||||
@ -120,6 +125,21 @@ class RefillForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Refilling
|
model = Refilling
|
||||||
fields = ["amount", "payment_method", "bank"]
|
fields = ["amount", "payment_method", "bank"]
|
||||||
|
widgets = {"payment_method": forms.RadioSelect}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields["payment_method"].choices = (
|
||||||
|
method
|
||||||
|
for method in self.fields["payment_method"].choices
|
||||||
|
if method[0] in self.allowed_refilling_methods
|
||||||
|
)
|
||||||
|
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
|
||||||
|
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
|
||||||
|
|
||||||
|
if "CHECK" not in self.allowed_refilling_methods:
|
||||||
|
del self.fields["bank"]
|
||||||
|
|
||||||
|
|
||||||
class CounterEditForm(forms.ModelForm):
|
class CounterEditForm(forms.ModelForm):
|
||||||
@ -134,6 +154,9 @@ class CounterEditForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class ProductEditForm(forms.ModelForm):
|
class ProductEditForm(forms.ModelForm):
|
||||||
|
error_css_class = "error"
|
||||||
|
required_css_class = "required"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
fields = [
|
fields = [
|
||||||
@ -151,6 +174,12 @@ class ProductEditForm(forms.ModelForm):
|
|||||||
"tray",
|
"tray",
|
||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
|
help_texts = {
|
||||||
|
"description": _(
|
||||||
|
"Describe the product. If it's an event's click, "
|
||||||
|
"give some insights about it, like the date (including the year)."
|
||||||
|
)
|
||||||
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
"product_type": AutoCompleteSelect,
|
"product_type": AutoCompleteSelect,
|
||||||
"buying_groups": AutoCompleteSelectMultipleGroup,
|
"buying_groups": AutoCompleteSelectMultipleGroup,
|
||||||
|
@ -127,7 +127,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# dumps and sales are linked to the same customers
|
# dumps and sales are linked to the same customers
|
||||||
# and or both ordered with the same key, so zipping them is valid
|
# and or both ordered with the same key, so zipping them is valid
|
||||||
for dump, sale in zip(pending_dumps, sales):
|
for dump, sale in zip(pending_dumps, sales, strict=False):
|
||||||
dump.dump_operation = sale
|
dump.dump_operation = sale
|
||||||
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
|
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
|
||||||
|
|
||||||
|
@ -1,38 +1,6 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
from counter.models import Counter, Customer, Product, Selling
|
|
||||||
|
|
||||||
|
|
||||||
def balance_ecocups(apps, schema_editor):
|
|
||||||
for customer in Customer.objects.all():
|
|
||||||
customer.recorded_products = 0
|
|
||||||
for selling in customer.buyings.filter(
|
|
||||||
product__id__in=[settings.SITH_ECOCUP_CONS, settings.SITH_ECOCUP_DECO]
|
|
||||||
).all():
|
|
||||||
if selling.product.is_record_product:
|
|
||||||
customer.recorded_products += selling.quantity
|
|
||||||
elif selling.product.is_unrecord_product:
|
|
||||||
customer.recorded_products -= selling.quantity
|
|
||||||
if customer.recorded_products < -settings.SITH_ECOCUP_LIMIT:
|
|
||||||
qt = -(customer.recorded_products + settings.SITH_ECOCUP_LIMIT)
|
|
||||||
cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
|
|
||||||
Selling(
|
|
||||||
label=_("Ecocup regularization"),
|
|
||||||
product=cons,
|
|
||||||
unit_price=cons.selling_price,
|
|
||||||
club=cons.club,
|
|
||||||
counter=Counter.objects.filter(name="Foyer").first(),
|
|
||||||
quantity=qt,
|
|
||||||
seller=User.objects.get(id=0),
|
|
||||||
customer=customer,
|
|
||||||
).save(allow_negative=True)
|
|
||||||
customer.recorded_products += qt
|
|
||||||
customer.save()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -44,5 +12,4 @@ class Migration(migrations.Migration):
|
|||||||
name="recorded_products",
|
name="recorded_products",
|
||||||
field=models.IntegerField(verbose_name="recorded items", default=0),
|
field=models.IntegerField(verbose_name="recorded items", default=0),
|
||||||
),
|
),
|
||||||
migrations.RunPython(balance_ecocups),
|
|
||||||
]
|
]
|
||||||
|
22
counter/migrations/0027_alter_refilling_payment_method.py
Normal file
22
counter/migrations/0027_alter_refilling_payment_method.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2024-12-15 22:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("counter", "0026_alter_studentcard_customer"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="refilling",
|
||||||
|
name="payment_method",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("CHECK", "Check"), ("CASH", "Cash"), ("CARD", "Credit card")],
|
||||||
|
default="CARD",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="payment method",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2024-12-15 17:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
|
||||||
|
|
||||||
|
def move_priority_to_order(apps: StateApps, schema_editor):
|
||||||
|
"""Migrate the previous homemade `priority` to `OrderedModel.order`.
|
||||||
|
|
||||||
|
`priority` was a system were click managers set themselves the priority
|
||||||
|
of a ProductType.
|
||||||
|
The higher the priority, the higher it was to be displayed in the eboutic.
|
||||||
|
Multiple product types could share the same priority, in which
|
||||||
|
case they were ordered by alphabetic order.
|
||||||
|
|
||||||
|
The new field is unique per object, and works in the other way :
|
||||||
|
the nearer from 0, the higher it should appear.
|
||||||
|
"""
|
||||||
|
ProductType = apps.get_model("counter", "ProductType")
|
||||||
|
product_types = list(ProductType.objects.order_by("-priority", "name"))
|
||||||
|
for order, product_type in enumerate(product_types):
|
||||||
|
product_type.order = order
|
||||||
|
ProductType.objects.bulk_update(product_types, ["order"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0027_alter_refilling_payment_method")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="producttype",
|
||||||
|
name="comment",
|
||||||
|
field=models.TextField(
|
||||||
|
default="",
|
||||||
|
help_text="A text that will be shown on the eboutic.",
|
||||||
|
verbose_name="comment",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="producttype",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(default="", verbose_name="description"),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="producttype",
|
||||||
|
options={"ordering": ["order"], "verbose_name": "product type"},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="producttype",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
db_index=True, default=0, editable=False, verbose_name="order"
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
move_priority_to_order,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
elidable=True,
|
||||||
|
),
|
||||||
|
migrations.RemoveField(model_name="producttype", name="priority"),
|
||||||
|
]
|
17
counter/migrations/0029_alter_selling_label.py
Normal file
17
counter/migrations/0029_alter_selling_label.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2024-12-22 22:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("counter", "0028_alter_producttype_comment_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="selling",
|
||||||
|
name="label",
|
||||||
|
field=models.CharField(max_length=128, verbose_name="label"),
|
||||||
|
),
|
||||||
|
]
|
@ -21,7 +21,7 @@ import string
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Self, Tuple
|
from typing import Self
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -35,6 +35,7 @@ from django.utils import timezone
|
|||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
|
from ordered_model.models import OrderedModel
|
||||||
from phonenumber_field.modelfields import PhoneNumberField
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
|
|
||||||
from accounting.models import CurrencyField
|
from accounting.models import CurrencyField
|
||||||
@ -42,7 +43,8 @@ from club.models import Club
|
|||||||
from core.fields import ResizedImageField
|
from core.fields import ResizedImageField
|
||||||
from core.models import Group, Notification, User
|
from core.models import Group, Notification, User
|
||||||
from core.utils import get_start_of_semester
|
from core.utils import get_start_of_semester
|
||||||
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
|
from counter.apps import PAYMENT_METHOD
|
||||||
|
from sith.settings import SITH_MAIN_CLUB
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
@ -136,7 +138,7 @@ class Customer(models.Model):
|
|||||||
return (date.today() - subscription.subscription_end) < timedelta(days=90)
|
return (date.today() - subscription.subscription_end) < timedelta(days=90)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_or_create(cls, user: User) -> Tuple[Customer, bool]:
|
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
|
||||||
"""Work in pretty much the same way as the usual get_or_create method,
|
"""Work in pretty much the same way as the usual get_or_create method,
|
||||||
but with the default field replaced by some under the hood.
|
but with the default field replaced by some under the hood.
|
||||||
|
|
||||||
@ -288,32 +290,32 @@ class AccountDump(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProductType(models.Model):
|
class ProductType(OrderedModel):
|
||||||
"""A product type.
|
"""A product type.
|
||||||
|
|
||||||
Useful only for categorizing.
|
Useful only for categorizing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=30)
|
name = models.CharField(_("name"), max_length=30)
|
||||||
description = models.TextField(_("description"), null=True, blank=True)
|
description = models.TextField(_("description"), default="")
|
||||||
comment = models.TextField(_("comment"), null=True, blank=True)
|
comment = models.TextField(
|
||||||
|
_("comment"),
|
||||||
|
default="",
|
||||||
|
help_text=_("A text that will be shown on the eboutic."),
|
||||||
|
)
|
||||||
icon = ResizedImageField(
|
icon = ResizedImageField(
|
||||||
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
|
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# priority holds no real backend logic but helps to handle the order in which
|
|
||||||
# the items are to be shown to the user
|
|
||||||
priority = models.PositiveIntegerField(default=0)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("product type")
|
verbose_name = _("product type")
|
||||||
ordering = ["-priority", "name"]
|
ordering = ["order"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("counter:producttype_list")
|
return reverse("counter:product_type_list")
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
def is_owned_by(self, user):
|
||||||
"""Method to see if that object can be edited by the given user."""
|
"""Method to see if that object can be edited by the given user."""
|
||||||
@ -325,6 +327,8 @@ class ProductType(models.Model):
|
|||||||
class Product(models.Model):
|
class Product(models.Model):
|
||||||
"""A product, with all its related information."""
|
"""A product, with all its related information."""
|
||||||
|
|
||||||
|
QUANTITY_FOR_TRAY_PRICE = 6
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=64)
|
name = models.CharField(_("name"), max_length=64)
|
||||||
description = models.TextField(_("description"), default="")
|
description = models.TextField(_("description"), default="")
|
||||||
product_type = models.ForeignKey(
|
product_type = models.ForeignKey(
|
||||||
@ -523,14 +527,17 @@ class Counter(models.Model):
|
|||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
mem = self.club.get_membership_for(user)
|
mem = self.club.get_membership_for(user)
|
||||||
if mem and mem.role >= 7:
|
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||||
return True
|
return True
|
||||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||||
|
|
||||||
def can_be_viewed_by(self, user: User) -> bool:
|
def can_be_viewed_by(self, user: User) -> bool:
|
||||||
if self.type == "BAR":
|
return (
|
||||||
return True
|
self.type == "BAR"
|
||||||
return user.is_board_member or user in self.sellers.all()
|
or user.is_root
|
||||||
|
or user.is_in_group(pk=self.club.board_group_id)
|
||||||
|
or user in self.sellers.all()
|
||||||
|
)
|
||||||
|
|
||||||
def gen_token(self) -> None:
|
def gen_token(self) -> None:
|
||||||
"""Generate a new token for this counter."""
|
"""Generate a new token for this counter."""
|
||||||
@ -558,9 +565,6 @@ class Counter(models.Model):
|
|||||||
"""Show if the counter authorize the refilling with physic money."""
|
"""Show if the counter authorize the refilling with physic money."""
|
||||||
if self.type != "BAR":
|
if self.type != "BAR":
|
||||||
return False
|
return False
|
||||||
if self.id in SITH_COUNTER_OFFICES:
|
|
||||||
# If the counter is either 'AE' or 'BdF', refills are authorized
|
|
||||||
return True
|
|
||||||
# at least one of the barmen is in the AE board
|
# at least one of the barmen is in the AE board
|
||||||
ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"])
|
ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"])
|
||||||
return any(ae.get_membership_for(barman) for barman in self.barmen_list)
|
return any(ae.get_membership_for(barman) for barman in self.barmen_list)
|
||||||
@ -650,6 +654,42 @@ class Counter(models.Model):
|
|||||||
)
|
)
|
||||||
)["total"]
|
)["total"]
|
||||||
|
|
||||||
|
def customer_is_barman(self, customer: Customer | User) -> bool:
|
||||||
|
"""Check if this counter is a `bar` and if the customer is currently logged in.
|
||||||
|
This is useful to compute special prices."""
|
||||||
|
|
||||||
|
# Customer and User are two different tables,
|
||||||
|
# but they share the same primary key
|
||||||
|
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||||
|
|
||||||
|
def get_products_for(self, customer: Customer) -> list[Product]:
|
||||||
|
"""
|
||||||
|
Get all allowed products for the provided customer on this counter
|
||||||
|
Prices will be annotated
|
||||||
|
"""
|
||||||
|
|
||||||
|
products = self.products.select_related("product_type").prefetch_related(
|
||||||
|
"buying_groups"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only include age appropriate products
|
||||||
|
age = customer.user.age
|
||||||
|
if customer.user.is_banned_alcohol:
|
||||||
|
age = min(age, 17)
|
||||||
|
products = products.filter(limit_age__lte=age)
|
||||||
|
|
||||||
|
# Compute special price for customer if he is a barmen on that bar
|
||||||
|
if self.customer_is_barman(customer):
|
||||||
|
products = products.annotate(price=F("special_selling_price"))
|
||||||
|
else:
|
||||||
|
products = products.annotate(price=F("selling_price"))
|
||||||
|
|
||||||
|
return [
|
||||||
|
product
|
||||||
|
for product in products.all()
|
||||||
|
if product.can_be_sold_to(customer.user)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class RefillingQuerySet(models.QuerySet):
|
class RefillingQuerySet(models.QuerySet):
|
||||||
def annotate_total(self) -> Self:
|
def annotate_total(self) -> Self:
|
||||||
@ -688,8 +728,8 @@ class Refilling(models.Model):
|
|||||||
payment_method = models.CharField(
|
payment_method = models.CharField(
|
||||||
_("payment method"),
|
_("payment method"),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
choices=settings.SITH_COUNTER_PAYMENT_METHOD,
|
choices=PAYMENT_METHOD,
|
||||||
default="CASH",
|
default="CARD",
|
||||||
)
|
)
|
||||||
bank = models.CharField(
|
bank = models.CharField(
|
||||||
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
|
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
|
||||||
@ -754,7 +794,8 @@ class SellingQuerySet(models.QuerySet):
|
|||||||
class Selling(models.Model):
|
class Selling(models.Model):
|
||||||
"""Handle the sellings."""
|
"""Handle the sellings."""
|
||||||
|
|
||||||
label = models.CharField(_("label"), max_length=64)
|
# We make sure that sellings have a way begger label than any product name is allowed to
|
||||||
|
label = models.CharField(_("label"), max_length=128)
|
||||||
product = models.ForeignKey(
|
product = models.ForeignKey(
|
||||||
Product,
|
Product,
|
||||||
related_name="sellings",
|
related_name="sellings",
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated, Self
|
||||||
|
|
||||||
from annotated_types import MinLen
|
from annotated_types import MinLen
|
||||||
from ninja import Field, FilterSchema, ModelSchema
|
from django.urls import reverse
|
||||||
|
from ninja import Field, FilterSchema, ModelSchema, Schema
|
||||||
|
from pydantic import model_validator
|
||||||
|
|
||||||
from core.schemas import SimpleUserSchema
|
from club.schemas import ClubSchema
|
||||||
from counter.models import Counter, Product
|
from core.schemas import GroupSchema, SimpleUserSchema
|
||||||
|
from counter.models import Counter, Product, ProductType
|
||||||
|
|
||||||
|
|
||||||
class CounterSchema(ModelSchema):
|
class CounterSchema(ModelSchema):
|
||||||
@ -26,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
|
|||||||
fields = ["id", "name"]
|
fields = ["id", "name"]
|
||||||
|
|
||||||
|
|
||||||
class ProductSchema(ModelSchema):
|
class ProductTypeSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = ProductType
|
||||||
|
fields = ["id", "name", "description", "comment", "icon", "order"]
|
||||||
|
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_url(obj: ProductType) -> str:
|
||||||
|
return reverse("counter:product_type_edit", kwargs={"type_id": obj.id})
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleProductTypeSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = ProductType
|
||||||
|
fields = ["id", "name"]
|
||||||
|
|
||||||
|
|
||||||
|
class ReorderProductTypeSchema(Schema):
|
||||||
|
below: int | None = None
|
||||||
|
above: int | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_exclusive(self) -> Self:
|
||||||
|
if self.below is None and self.above is None:
|
||||||
|
raise ValueError("Either 'below' or 'above' must be set.")
|
||||||
|
if self.below is not None and self.above is not None:
|
||||||
|
raise ValueError("Only one of 'below' or 'above' must be set.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleProductSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
fields = ["id", "name", "code"]
|
fields = ["id", "name", "code"]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSchema(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"code",
|
||||||
|
"description",
|
||||||
|
"purchase_price",
|
||||||
|
"selling_price",
|
||||||
|
"icon",
|
||||||
|
"limit_age",
|
||||||
|
"archived",
|
||||||
|
]
|
||||||
|
|
||||||
|
buying_groups: list[GroupSchema]
|
||||||
|
club: ClubSchema
|
||||||
|
product_type: SimpleProductTypeSchema | None
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_url(obj: Product) -> str:
|
||||||
|
return reverse("counter:product_edit", kwargs={"product_id": obj.id})
|
||||||
|
|
||||||
|
|
||||||
|
class ProductFilterSchema(FilterSchema):
|
||||||
|
search: Annotated[str, MinLen(1)] | None = Field(
|
||||||
|
None, q=["name__icontains", "code__icontains"]
|
||||||
|
)
|
||||||
|
is_archived: bool | None = Field(None, q="archived")
|
||||||
|
buying_groups: set[int] | None = Field(None, q="buying_groups__in")
|
||||||
|
product_type: set[int] | None = Field(None, q="product_type__in")
|
||||||
|
25
counter/static/bundled/counter/basket.ts
Normal file
25
counter/static/bundled/counter/basket.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { Product } from "#counter:counter/types";
|
||||||
|
|
||||||
|
export class BasketItem {
|
||||||
|
quantity: number;
|
||||||
|
product: Product;
|
||||||
|
quantityForTrayPrice: number;
|
||||||
|
errors: string[];
|
||||||
|
|
||||||
|
constructor(product: Product, quantity: number) {
|
||||||
|
this.quantity = quantity;
|
||||||
|
this.product = product;
|
||||||
|
this.errors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getBonusQuantity(): number {
|
||||||
|
if (!this.product.hasTrayPrice) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.floor(this.quantity / this.product.quantityForTrayPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
sum(): number {
|
||||||
|
return (this.quantity - this.getBonusQuantity()) * this.product.price;
|
||||||
|
}
|
||||||
|
}
|
@ -4,9 +4,11 @@ import type { TomOption } from "tom-select/dist/types/types";
|
|||||||
import type { escape_html } from "tom-select/dist/types/utils";
|
import type { escape_html } from "tom-select/dist/types/utils";
|
||||||
import {
|
import {
|
||||||
type CounterSchema,
|
type CounterSchema,
|
||||||
type ProductSchema,
|
type ProductTypeSchema,
|
||||||
|
type SimpleProductSchema,
|
||||||
counterSearchCounter,
|
counterSearchCounter,
|
||||||
productSearchProducts,
|
productSearchProducts,
|
||||||
|
producttypeFetchAll,
|
||||||
} from "#openapi";
|
} from "#openapi";
|
||||||
|
|
||||||
@registerComponent("product-ajax-select")
|
@registerComponent("product-ajax-select")
|
||||||
@ -23,17 +25,48 @@ export class ProductAjaxSelect extends AjaxSelect {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
|
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
||||||
return `<div class="select-item">
|
return `<div class="select-item">
|
||||||
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
|
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected renderItem(item: ProductSchema, sanitize: typeof escape_html) {
|
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
||||||
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
|
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@registerComponent("product-type-ajax-select")
|
||||||
|
export class ProductTypeAjaxSelect extends AjaxSelect {
|
||||||
|
protected valueField = "id";
|
||||||
|
protected labelField = "name";
|
||||||
|
protected searchField = ["name"];
|
||||||
|
private productTypes = null as ProductTypeSchema[];
|
||||||
|
|
||||||
|
protected async search(query: string): Promise<TomOption[]> {
|
||||||
|
// The production database has a grand total of 26 product types
|
||||||
|
// and the filter logic is really simple.
|
||||||
|
// Thus, it's appropriate to fetch all product types during first use,
|
||||||
|
// then to reuse the result again and again.
|
||||||
|
if (this.productTypes === null) {
|
||||||
|
this.productTypes = (await producttypeFetchAll()).data || null;
|
||||||
|
}
|
||||||
|
return this.productTypes.filter((t) =>
|
||||||
|
t.name.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renderOption(item: ProductTypeSchema, sanitize: typeof escape_html) {
|
||||||
|
return `<div class="select-item">
|
||||||
|
<span class="select-item-text">${sanitize(item.name)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renderItem(item: ProductTypeSchema, sanitize: typeof escape_html) {
|
||||||
|
return `<span>${sanitize(item.name)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@registerComponent("counter-ajax-select")
|
@registerComponent("counter-ajax-select")
|
||||||
export class CounterAjaxSelect extends AjaxSelect {
|
export class CounterAjaxSelect extends AjaxSelect {
|
||||||
protected valueField = "id";
|
protected valueField = "id";
|
||||||
|
@ -0,0 +1,90 @@
|
|||||||
|
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
|
||||||
|
import { registerComponent } from "#core:utils/web-components";
|
||||||
|
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
|
||||||
|
|
||||||
|
const productParsingRegex = /^(\d+x)?(.*)/i;
|
||||||
|
const codeParsingRegex = / \((\w+)\)$/;
|
||||||
|
|
||||||
|
function parseProduct(query: string): [number, string] {
|
||||||
|
const parsed = productParsingRegex.exec(query);
|
||||||
|
return [Number.parseInt(parsed[1] || "1"), parsed[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
@registerComponent("counter-product-select")
|
||||||
|
export class CounterProductSelect extends AutoCompleteSelectBase {
|
||||||
|
public getOperationCodes(): string[] {
|
||||||
|
return ["FIN", "ANN"];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSelectedProduct(): [number, string] {
|
||||||
|
return parseProduct(this.widget.getValue() as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected attachBehaviors(): void {
|
||||||
|
this.allowMultipleProducts();
|
||||||
|
this.parseCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCodes(): void {
|
||||||
|
// We guess the code from the product name so we can prioritize search on it
|
||||||
|
// If no code is found, we just ignore it and everything still is fine
|
||||||
|
for (const option of Object.values(this.widget.options)) {
|
||||||
|
const match = codeParsingRegex.exec(option.text);
|
||||||
|
if (match !== null) {
|
||||||
|
option.code = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private allowMultipleProducts(): void {
|
||||||
|
const search = this.widget.search;
|
||||||
|
const onOptionSelect = this.widget.onOptionSelect;
|
||||||
|
this.widget.hook("instead", "search", (query: string) => {
|
||||||
|
return search.call(this.widget, parseProduct(query)[1]);
|
||||||
|
});
|
||||||
|
this.widget.hook(
|
||||||
|
"instead",
|
||||||
|
"onOptionSelect",
|
||||||
|
(evt: MouseEvent | KeyboardEvent, option: HTMLElement) => {
|
||||||
|
const [quantity, _] = parseProduct(this.widget.inputValue());
|
||||||
|
const originalValue = option.getAttribute("data-value") ?? option.innerText;
|
||||||
|
|
||||||
|
if (quantity === 1 || this.getOperationCodes().includes(originalValue)) {
|
||||||
|
return onOptionSelect.call(this.widget, evt, option);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = `${quantity}x${originalValue}`;
|
||||||
|
const label = `${quantity}x${option.innerText}`;
|
||||||
|
this.widget.addOption({ value: value, text: label }, true);
|
||||||
|
return onOptionSelect.call(
|
||||||
|
this.widget,
|
||||||
|
evt,
|
||||||
|
this.widget.getOption(value, true),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.widget.hook("after", "onOptionSelect", () => {
|
||||||
|
/* Focus the next element if it's an input */
|
||||||
|
if (this.nextElementSibling.nodeName === "INPUT") {
|
||||||
|
(this.nextElementSibling as HTMLInputElement).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
protected tomSelectSettings(): RecursivePartial<TomSettings> {
|
||||||
|
/* We disable the dropdown on focus because we're going to always autofocus the widget */
|
||||||
|
return {
|
||||||
|
...super.tomSelectSettings(),
|
||||||
|
openOnFocus: false,
|
||||||
|
// We make searching on exact code matching a higher priority
|
||||||
|
// We need to manually set weights or it results on an inconsistent
|
||||||
|
// behavior between production and development environment
|
||||||
|
searchField: [
|
||||||
|
// @ts-ignore documentation says it's fine, specified type is wrong
|
||||||
|
{ field: "code", weight: 2 },
|
||||||
|
// @ts-ignore documentation says it's fine, specified type is wrong
|
||||||
|
{ field: "text", weight: 0.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
155
counter/static/bundled/counter/counter-click-index.ts
Normal file
155
counter/static/bundled/counter/counter-click-index.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { exportToHtml } from "#core:utils/globals";
|
||||||
|
import { BasketItem } from "#counter:counter/basket";
|
||||||
|
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
|
||||||
|
|
||||||
|
exportToHtml("loadCounter", (config: CounterConfig) => {
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("counter", () => ({
|
||||||
|
basket: {} as Record<string, BasketItem>,
|
||||||
|
errors: [],
|
||||||
|
customerBalance: config.customerBalance,
|
||||||
|
codeField: undefined,
|
||||||
|
alertMessage: {
|
||||||
|
content: "",
|
||||||
|
show: false,
|
||||||
|
timeout: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Fill the basket with the initial data
|
||||||
|
for (const entry of config.formInitial) {
|
||||||
|
if (entry.id !== undefined && entry.quantity !== undefined) {
|
||||||
|
this.addToBasket(entry.id, entry.quantity);
|
||||||
|
this.basket[entry.id].errors = entry.errors ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.codeField = this.$refs.codeField;
|
||||||
|
this.codeField.widget.focus();
|
||||||
|
|
||||||
|
// It's quite tricky to manually apply attributes to the management part
|
||||||
|
// of a formset so we dynamically apply it here
|
||||||
|
this.$refs.basketManagementForm
|
||||||
|
.querySelector("#id_form-TOTAL_FORMS")
|
||||||
|
.setAttribute(":value", "getBasketSize()");
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromBasket(id: string) {
|
||||||
|
delete this.basket[id];
|
||||||
|
},
|
||||||
|
|
||||||
|
addToBasket(id: string, quantity: number): ErrorMessage {
|
||||||
|
const item: BasketItem =
|
||||||
|
this.basket[id] || new BasketItem(config.products[id], 0);
|
||||||
|
|
||||||
|
const oldQty = item.quantity;
|
||||||
|
item.quantity += quantity;
|
||||||
|
|
||||||
|
if (item.quantity <= 0) {
|
||||||
|
delete this.basket[id];
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.basket[id] = item;
|
||||||
|
|
||||||
|
if (this.sumBasket() > this.customerBalance) {
|
||||||
|
item.quantity = oldQty;
|
||||||
|
if (item.quantity === 0) {
|
||||||
|
delete this.basket[id];
|
||||||
|
}
|
||||||
|
return gettext("Not enough money");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
|
||||||
|
getBasketSize() {
|
||||||
|
return Object.keys(this.basket).length;
|
||||||
|
},
|
||||||
|
|
||||||
|
sumBasket() {
|
||||||
|
if (this.getBasketSize() === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const total = Object.values(this.basket).reduce(
|
||||||
|
(acc: number, cur: BasketItem) => acc + cur.sum(),
|
||||||
|
0,
|
||||||
|
) as number;
|
||||||
|
return total;
|
||||||
|
},
|
||||||
|
|
||||||
|
showAlertMessage(message: string) {
|
||||||
|
if (this.alertMessage.timeout !== null) {
|
||||||
|
clearTimeout(this.alertMessage.timeout);
|
||||||
|
}
|
||||||
|
this.alertMessage.content = message;
|
||||||
|
this.alertMessage.show = true;
|
||||||
|
this.alertMessage.timeout = setTimeout(() => {
|
||||||
|
this.alertMessage.show = false;
|
||||||
|
this.alertMessage.timeout = null;
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
addToBasketWithMessage(id: string, quantity: number) {
|
||||||
|
const message = this.addToBasket(id, quantity);
|
||||||
|
if (message.length > 0) {
|
||||||
|
this.showAlertMessage(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onRefillingSuccess(event: CustomEvent) {
|
||||||
|
if (event.type !== "htmx:after-request" || event.detail.failed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.customerBalance += Number.parseFloat(
|
||||||
|
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
|
||||||
|
);
|
||||||
|
document.getElementById("selling-accordion").click();
|
||||||
|
this.codeField.widget.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
finish() {
|
||||||
|
if (this.getBasketSize() === 0) {
|
||||||
|
this.showAlertMessage(gettext("You can't send an empty basket."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$refs.basketForm.submit();
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
location.href = config.cancelUrl;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCode() {
|
||||||
|
const [quantity, code] = this.codeField.getSelectedProduct() as [
|
||||||
|
number,
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
|
||||||
|
if (code === "ANN") {
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
if (code === "FIN") {
|
||||||
|
this.finish();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.addToBasketWithMessage(code, quantity);
|
||||||
|
}
|
||||||
|
this.codeField.widget.clear();
|
||||||
|
this.codeField.widget.focus();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$(() => {
|
||||||
|
/* Accordion UI between basket and refills */
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||||
|
($("#click-form") as any).accordion({
|
||||||
|
heightStyle: "content",
|
||||||
|
activate: () => $(".focus").focus(),
|
||||||
|
});
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||||
|
($("#products") as any).tabs();
|
||||||
|
});
|
163
counter/static/bundled/counter/product-list-index.ts
Normal file
163
counter/static/bundled/counter/product-list-index.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { paginated } from "#core:utils/api";
|
||||||
|
import { csv } from "#core:utils/csv";
|
||||||
|
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
|
||||||
|
import type { NestedKeyOf } from "#core:utils/types";
|
||||||
|
import { showSaveFilePicker } from "native-file-system-adapter";
|
||||||
|
import type TomSelect from "tom-select";
|
||||||
|
import {
|
||||||
|
type ProductSchema,
|
||||||
|
type ProductSearchProductsDetailedData,
|
||||||
|
productSearchProductsDetailed,
|
||||||
|
} from "#openapi";
|
||||||
|
|
||||||
|
type ProductType = string;
|
||||||
|
type GroupedProducts = Record<ProductType, ProductSchema[]>;
|
||||||
|
|
||||||
|
const defaultPageSize = 100;
|
||||||
|
const defaultPage = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys of the properties to include in the CSV.
|
||||||
|
*/
|
||||||
|
const csvColumns = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"code",
|
||||||
|
"description",
|
||||||
|
"product_type.name",
|
||||||
|
"club.name",
|
||||||
|
"limit_age",
|
||||||
|
"purchase_price",
|
||||||
|
"selling_price",
|
||||||
|
"archived",
|
||||||
|
] as NestedKeyOf<ProductSchema>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title of the csv columns.
|
||||||
|
*/
|
||||||
|
const csvColumnTitles = [
|
||||||
|
"id",
|
||||||
|
gettext("name"),
|
||||||
|
"code",
|
||||||
|
"description",
|
||||||
|
gettext("product type"),
|
||||||
|
"club",
|
||||||
|
gettext("limit age"),
|
||||||
|
gettext("purchase price"),
|
||||||
|
gettext("selling price"),
|
||||||
|
gettext("archived"),
|
||||||
|
];
|
||||||
|
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("productList", () => ({
|
||||||
|
loading: false,
|
||||||
|
csvLoading: false,
|
||||||
|
products: {} as GroupedProducts,
|
||||||
|
|
||||||
|
/** Total number of elements corresponding to the current query. */
|
||||||
|
nbPages: 0,
|
||||||
|
|
||||||
|
productStatus: "" as "active" | "archived" | "both",
|
||||||
|
search: "",
|
||||||
|
productTypes: [] as string[],
|
||||||
|
pageSize: defaultPageSize,
|
||||||
|
page: defaultPage,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const url = getCurrentUrlParams();
|
||||||
|
this.search = url.get("search") || "";
|
||||||
|
this.productStatus = url.get("productStatus") ?? "active";
|
||||||
|
const widget = this.$refs.productTypesInput.widget as TomSelect;
|
||||||
|
widget.on("change", (items: string[]) => {
|
||||||
|
this.productTypes = [...items];
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.load();
|
||||||
|
const searchParams = ["search", "productStatus", "productTypes"];
|
||||||
|
for (const param of searchParams) {
|
||||||
|
this.$watch(param, () => {
|
||||||
|
this.page = defaultPage;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const param of [...searchParams, "page"]) {
|
||||||
|
this.$watch(param, async (value: string) => {
|
||||||
|
updateQueryString(param, value, History.Replace);
|
||||||
|
this.nbPages = 0;
|
||||||
|
await this.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the object containing the query parameters corresponding
|
||||||
|
* to the current filters
|
||||||
|
*/
|
||||||
|
getQueryParams(): ProductSearchProductsDetailedData {
|
||||||
|
const search = this.search.length > 0 ? this.search : null;
|
||||||
|
// If active or archived products must be filtered, put the filter in the request
|
||||||
|
// Else, don't include the filter
|
||||||
|
const isArchived = ["active", "archived"].includes(this.productStatus)
|
||||||
|
? this.productStatus === "archived"
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
query: {
|
||||||
|
page: this.page,
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
page_size: this.pageSize,
|
||||||
|
search: search,
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
is_archived: isArchived,
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
product_type: [...this.productTypes],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the products corresponding to the current filters
|
||||||
|
*/
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
const options = this.getQueryParams();
|
||||||
|
const resp = await productSearchProductsDetailed(options);
|
||||||
|
this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
|
||||||
|
this.products = resp.data.results.reduce<GroupedProducts>((acc, curr) => {
|
||||||
|
const key = curr.product_type?.name ?? gettext("Uncategorized");
|
||||||
|
if (!(key in acc)) {
|
||||||
|
acc[key] = [];
|
||||||
|
}
|
||||||
|
acc[key].push(curr);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download products corresponding to the current filters as a CSV file.
|
||||||
|
* If the pagination has multiple pages, all pages are downloaded.
|
||||||
|
*/
|
||||||
|
async downloadCsv() {
|
||||||
|
this.csvLoading = true;
|
||||||
|
const fileHandle = await showSaveFilePicker({
|
||||||
|
_preferPolyfill: false,
|
||||||
|
suggestedName: gettext("products.csv"),
|
||||||
|
types: [],
|
||||||
|
excludeAcceptAllOption: false,
|
||||||
|
});
|
||||||
|
// if products to download are already in-memory, directly take them.
|
||||||
|
// If not, fetch them.
|
||||||
|
const products =
|
||||||
|
this.nbPages > 1
|
||||||
|
? await paginated(productSearchProductsDetailed, this.getQueryParams())
|
||||||
|
: Object.values<ProductSchema[]>(this.products).flat();
|
||||||
|
const content = csv.stringify(products, {
|
||||||
|
columns: csvColumns,
|
||||||
|
titleRow: csvColumnTitles,
|
||||||
|
});
|
||||||
|
const file = await fileHandle.createWritable();
|
||||||
|
await file.write(content);
|
||||||
|
await file.close();
|
||||||
|
this.csvLoading = false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user