mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-12 21:09:24 +00:00
Compare commits
227 Commits
counter-ac
...
windows-up
Author | SHA1 | Date | |
---|---|---|---|
1d03fcf6ea | |||
a6ba65a494 | |||
c90fcc838e | |||
bc9cb9b36c | |||
edafc06c3f | |||
134f8a7989 | |||
771cbdbd77 | |||
a491baddb9 | |||
8d10a5e0ab | |||
cbe42d3a60 | |||
0c4d72e17a | |||
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 | |||
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 | |||
f5d5cc18a8 | |||
4c65939bbe | |||
379527cd58 | |||
f63fb59cbf | |||
cde864fdc7 | |||
e9361697f7 | |||
830c752971 | |||
6bdc1b73ae | |||
0f003870bb | |||
0631c77a1c | |||
2cc4308a58 | |||
4975475e85 | |||
466fe58763 | |||
3b7e338808 | |||
53b13e7aef | |||
fa60ecb25a | |||
a975824481 | |||
c51e5eb6cb | |||
f0bc502ec9 | |||
902cafc5e4 | |||
b2f54aa23e | |||
29a5425259 | |||
e2a34c75ea | |||
de7aa6f6a6 | |||
9acb421b2e | |||
66d2dc74e7 | |||
2f613607af | |||
d4b9c3afb1 | |||
b81cf49d0a | |||
1da45fdffc | |||
10dde3f002 | |||
c2d6af12ab | |||
6e48f88c06 | |||
7a91a71565 | |||
c4764110d8 | |||
ff68e65250 | |||
c9d83e5916 | |||
5dc99dbfcb | |||
8dbec85c8e | |||
84d7e40e66 | |||
0b509f2200 | |||
9591162cc9 | |||
007e17fd8b | |||
95f8e7517c | |||
9667c79162 | |||
1c79c25262 | |||
04b4b34bfe | |||
fc0e689d4e | |||
83bb4b3b12 | |||
8dcfc604a0 | |||
d2d639e5f6 | |||
b3eb7693e3 | |||
10f42b1522 | |||
76e9f3b1dc | |||
d0ff9bc16c | |||
5e4ebd16f9 | |||
d2b19424ff | |||
08286254cd | |||
4805c39b45 | |||
f845bbf20a | |||
71c7158124 | |||
c4643ee52c | |||
b46b0882f3 | |||
1c4efc9431 | |||
4133e0ccdd | |||
de415e7e75 | |||
9d17524f45 | |||
68ad9650af | |||
8d4d8a3abc | |||
9617e29ed5 | |||
75406f7b58 | |||
70f5ae4f9c |
14
.envrc
14
.envrc
@ -1,14 +1,6 @@
|
||||
if [[ ! -f pyproject.toml ]]; then
|
||||
log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
|
||||
if [[ ! -d .venv ]]; then
|
||||
log_error 'No .venv folder found. Use `uv sync` to create one first.'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
local VENV=$(poetry env list --full-path | cut -d' ' -f1)
|
||||
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"
|
||||
. .venv/bin/activate
|
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
|
||||
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
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Load cached Poetry installation
|
||||
id: cached-poetry
|
||||
uses: actions/cache@v3
|
||||
- name: Restore cached virtualenv
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ~/.local
|
||||
key: poetry-3 # increment to reset cache
|
||||
|
||||
- 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-
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||
path: .venv
|
||||
|
||||
- name: Install dependencies
|
||||
run: poetry install --with docs,tests
|
||||
run: uv sync
|
||||
shell: bash
|
||||
|
||||
- name: Install xapian
|
||||
run: poetry run ./manage.py install_xapian
|
||||
- name: Install Xapian
|
||||
run: uv run ./manage.py install_xapian
|
||||
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
|
||||
run: poetry run ./manage.py compilemessages
|
||||
run: uv run ./manage.py compilemessages
|
||||
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
|
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@ -14,6 +14,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- uses: pre-commit/action@v3.0.1
|
||||
with:
|
||||
extra_args: --all-files
|
||||
@ -29,14 +31,15 @@ jobs:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup_project
|
||||
- uses: ./.github/actions/setup_xapian
|
||||
- uses: ./.github/actions/compile_messages
|
||||
env:
|
||||
# To avoid race conditions on environment cache
|
||||
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
|
||||
- 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
|
||||
run: |
|
||||
poetry run coverage report
|
||||
poetry run coverage html
|
||||
uv run coverage report
|
||||
uv run coverage html
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
28
.github/workflows/deploy.yml
vendored
28
.github/workflows/deploy.yml
vendored
@ -37,11 +37,29 @@ jobs:
|
||||
|
||||
git fetch
|
||||
git reset --hard origin/master
|
||||
poetry install --with prod --without docs,tests
|
||||
uv sync --group prod
|
||||
npm install
|
||||
poetry run ./manage.py install_xapian
|
||||
poetry run ./manage.py migrate
|
||||
poetry run ./manage.py collectstatic --clear --noinput
|
||||
poetry run ./manage.py compilemessages
|
||||
uv run ./manage.py install_xapian
|
||||
uv run ./manage.py migrate
|
||||
uv run ./manage.py collectstatic --clear --noinput
|
||||
uv run ./manage.py compilemessages
|
||||
|
||||
sudo systemctl restart uwsgi
|
||||
|
||||
sentry:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
timeout-minutes: 30
|
||||
needs: deployment
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sentry Release
|
||||
uses: getsentry/action-release@v1.7.0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
with:
|
||||
environment: production
|
2
.github/workflows/deploy_docs.yml
vendored
2
.github/workflows/deploy_docs.yml
vendored
@ -18,4 +18,4 @@ jobs:
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
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 reset --hard origin/taiste
|
||||
poetry install --with prod --without docs,tests
|
||||
uv sync --group prod
|
||||
npm install
|
||||
poetry run ./manage.py install_xapian
|
||||
poetry run ./manage.py migrate
|
||||
poetry run ./manage.py collectstatic --clear --noinput
|
||||
poetry run ./manage.py compilemessages
|
||||
uv run ./manage.py install_xapian
|
||||
uv run ./manage.py migrate
|
||||
uv run ./manage.py collectstatic --clear --noinput
|
||||
uv run ./manage.py compilemessages
|
||||
|
||||
sudo systemctl restart uwsgi
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,7 +8,7 @@ pyrightconfig.json
|
||||
dist/
|
||||
.vscode/
|
||||
.idea/
|
||||
env/
|
||||
.venv/
|
||||
doc/html
|
||||
data/
|
||||
galaxy/test_galaxy_state.json
|
||||
|
@ -1,7 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.6.9
|
||||
rev: v0.8.3
|
||||
hooks:
|
||||
- id: ruff # just check the code, and print the errors
|
||||
- id: ruff # actually fix the fixable errors, but print nothing
|
||||
@ -14,7 +14,7 @@ repos:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@1.9.3"]
|
||||
- repo: https://github.com/rtts/djhtml
|
||||
rev: 3.0.6
|
||||
rev: 3.0.7
|
||||
hooks:
|
||||
- id: djhtml
|
||||
name: format templates
|
||||
|
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()
|
||||
)
|
||||
|
||||
def test__operation_simple_accounting(self):
|
||||
def test_operation_simple_accounting(self):
|
||||
sat = SimplifiedAccountingType.objects.all().first()
|
||||
response = self.client.post(
|
||||
reverse("accounting:op_new", args=[self.journal.id]),
|
||||
@ -237,15 +237,14 @@ class TestOperation(TestCase):
|
||||
"done": False,
|
||||
},
|
||||
)
|
||||
self.assertFalse(response.status_code == 403)
|
||||
self.assertTrue(self.journal.operations.filter(amount=23).exists())
|
||||
assert response.status_code != 403
|
||||
assert self.journal.operations.filter(amount=23).exists()
|
||||
response_get = self.client.get(
|
||||
reverse("accounting:journal_details", args=[self.journal.id])
|
||||
)
|
||||
self.assertTrue(
|
||||
"<td>Le fantome de l'aurore</td>" in str(response_get.content)
|
||||
)
|
||||
self.assertTrue(
|
||||
assert "<td>Le fantome de l'aurore</td>" in str(response_get.content)
|
||||
|
||||
assert (
|
||||
self.journal.operations.filter(amount=23)
|
||||
.values("accounting_type")
|
||||
.first()["accounting_type"]
|
||||
|
@ -215,17 +215,14 @@ class JournalTabsMixin(TabedViewMixin):
|
||||
return _("Journal")
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
tab_list = []
|
||||
tab_list.append(
|
||||
return [
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_details", kwargs={"j_id": self.object.id}
|
||||
),
|
||||
"slug": "journal",
|
||||
"name": _("Journal"),
|
||||
}
|
||||
)
|
||||
tab_list.append(
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_nature_statement",
|
||||
@ -233,9 +230,7 @@ class JournalTabsMixin(TabedViewMixin):
|
||||
),
|
||||
"slug": "nature_statement",
|
||||
"name": _("Statement by nature"),
|
||||
}
|
||||
)
|
||||
tab_list.append(
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_person_statement",
|
||||
@ -243,9 +238,7 @@ class JournalTabsMixin(TabedViewMixin):
|
||||
),
|
||||
"slug": "person_statement",
|
||||
"name": _("Statement by person"),
|
||||
}
|
||||
)
|
||||
tab_list.append(
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"accounting:journal_accounting_statement",
|
||||
@ -253,9 +246,8 @@ class JournalTabsMixin(TabedViewMixin):
|
||||
),
|
||||
"slug": "accounting_statement",
|
||||
"name": _("Accounting statement"),
|
||||
}
|
||||
)
|
||||
return tab_list
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class JournalCreateView(CanCreateMixin, CreateView):
|
||||
|
@ -20,6 +20,14 @@ from club.models import Club, Membership
|
||||
@admin.register(Club)
|
||||
class ClubAdmin(admin.ModelAdmin):
|
||||
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)
|
||||
|
@ -3,19 +3,6 @@ from __future__ import unicode_literals
|
||||
import django.db.models.deletion
|
||||
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):
|
||||
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
|
||||
@ -48,11 +35,4 @@ class Migration(migrations.Migration):
|
||||
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",
|
||||
),
|
||||
),
|
||||
]
|
302
club/models.py
302
club/models.py
@ -23,7 +23,7 @@
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Self
|
||||
from typing import Iterable, Self
|
||||
|
||||
from django.conf import settings
|
||||
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.validators import RegexValidator, validate_email
|
||||
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.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import localdate
|
||||
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.
|
||||
|
||||
@ -79,19 +79,6 @@ class Club(models.Model):
|
||||
_("short description"), max_length=1000, default="", blank=True, null=True
|
||||
)
|
||||
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(
|
||||
SithFile,
|
||||
related_name="home_of_club",
|
||||
@ -103,6 +90,12 @@ class Club(models.Model):
|
||||
page = models.OneToOneField(
|
||||
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:
|
||||
ordering = ["name", "unix_name"]
|
||||
@ -112,23 +105,27 @@ class Club(models.Model):
|
||||
|
||||
@transaction.atomic()
|
||||
def save(self, *args, **kwargs):
|
||||
old = Club.objects.filter(id=self.id).first()
|
||||
creation = old is None
|
||||
if not creation and old.unix_name != self.unix_name:
|
||||
self._change_unixname(self.unix_name)
|
||||
creation = self._state.adding
|
||||
if not creation:
|
||||
db_club = Club.objects.get(id=self.id)
|
||||
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)
|
||||
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.home.edit_groups.set([board])
|
||||
self.home.view_groups.set([member, subscribers])
|
||||
self.home.save()
|
||||
self.make_page()
|
||||
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})
|
||||
|
||||
@cached_property
|
||||
def president(self):
|
||||
def president(self) -> Membership | None:
|
||||
"""Fetch the membership of the current president of this club."""
|
||||
return self.members.filter(
|
||||
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
||||
).first()
|
||||
@ -154,27 +152,9 @@ class Club(models.Model):
|
||||
def clean(self):
|
||||
self.check_loop()
|
||||
|
||||
def _change_unixname(self, old_name, new_name):
|
||||
c = Club.objects.filter(unix_name=new_name).first()
|
||||
if c is None:
|
||||
# Update all the groups names
|
||||
Group.objects.filter(name=old_name).update(name=new_name)
|
||||
Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
|
||||
name=new_name + settings.SITH_BOARD_SUFFIX
|
||||
)
|
||||
Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
|
||||
name=new_name + settings.SITH_MEMBER_SUFFIX
|
||||
)
|
||||
|
||||
def make_home(self) -> None:
|
||||
if self.home:
|
||||
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:
|
||||
return
|
||||
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
|
||||
root = User.objects.filter(username="root").first()
|
||||
if home_root and root:
|
||||
@ -183,7 +163,7 @@ class Club(models.Model):
|
||||
self.home = home
|
||||
self.save()
|
||||
|
||||
def make_page(self):
|
||||
def make_page(self) -> None:
|
||||
root = User.objects.filter(username="root").first()
|
||||
if not self.page:
|
||||
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.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
|
||||
for membership in self.members.ongoing().select_related("user"):
|
||||
cache.delete(f"membership_{self.id}_{membership.user.id}")
|
||||
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
|
||||
|
||||
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."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return user.is_board_member
|
||||
return user.is_root or user.is_board_member
|
||||
|
||||
def get_full_logo_url(self):
|
||||
return "https://%s%s" % (settings.SITH_URL, self.logo.url)
|
||||
def get_full_logo_url(self) -> str:
|
||||
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."""
|
||||
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."""
|
||||
sub = User.objects.filter(pk=user.pk).first()
|
||||
if sub is None:
|
||||
return False
|
||||
return sub.was_subscribed
|
||||
return user.was_subscribed
|
||||
|
||||
def get_membership_for(self, user: User) -> Membership | None:
|
||||
"""Return the current membership the given user.
|
||||
@ -262,9 +241,8 @@ class Club(models.Model):
|
||||
cache.set(f"membership_{self.id}_{user.id}", membership)
|
||||
return membership
|
||||
|
||||
def has_rights_in_club(self, user):
|
||||
m = self.get_membership_for(user)
|
||||
return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
|
||||
def has_rights_in_club(self, user: User) -> bool:
|
||||
return user.is_in_group(pk=self.board_group_id)
|
||||
|
||||
|
||||
class MembershipQuerySet(models.QuerySet):
|
||||
@ -283,42 +261,65 @@ class MembershipQuerySet(models.QuerySet):
|
||||
"""
|
||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
|
||||
def update(self, **kwargs):
|
||||
"""Refresh the cache for the elements of the queryset.
|
||||
def update(self, **kwargs) -> int:
|
||||
"""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)
|
||||
if nb_rows > 0:
|
||||
# if at least a row was affected, refresh the cache
|
||||
for membership in self.all():
|
||||
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,
|
||||
)
|
||||
if nb_rows == 0:
|
||||
# if no row was affected, no need to refresh the cache
|
||||
return 0
|
||||
|
||||
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,
|
||||
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.
|
||||
As this first query take place before the deletion operation,
|
||||
it will be performed even if the deletion fails.
|
||||
Be aware that this adds some db queries :
|
||||
|
||||
- 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"))
|
||||
nb_rows, _ = super().delete()
|
||||
memberships = set(self.all())
|
||||
nb_rows, rows_counts = super().delete()
|
||||
if nb_rows > 0:
|
||||
for club_id, user_id in ids:
|
||||
cache.set(f"membership_{club_id}_{user_id}", "not_member")
|
||||
Membership._remove_club_groups(memberships)
|
||||
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):
|
||||
@ -361,6 +362,13 @@ class Membership(models.Model):
|
||||
|
||||
objects = MembershipQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=Q(end_date__gte=F("start_date")), name="end_after_start"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.club.name} - {self.user.username} "
|
||||
@ -370,7 +378,14 @@ class Membership(models.Model):
|
||||
|
||||
def save(self, *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:
|
||||
self._add_club_groups([self])
|
||||
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
||||
else:
|
||||
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):
|
||||
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."""
|
||||
if user.is_anonymous:
|
||||
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:
|
||||
"""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
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self._remove_club_groups([self])
|
||||
super().delete(*args, **kwargs)
|
||||
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):
|
||||
"""A Mailing list for a club.
|
||||
@ -438,14 +535,13 @@ class Mailing(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.is_moderated:
|
||||
for user in (
|
||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
.first()
|
||||
.users.all()
|
||||
unread_notif_subquery = Notification.objects.filter(
|
||||
user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
|
||||
)
|
||||
for user in User.objects.filter(
|
||||
~Exists(unread_notif_subquery),
|
||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||
):
|
||||
if not user.notifications.filter(
|
||||
type="MAILING_MODERATION", viewed=False
|
||||
).exists():
|
||||
Notification(
|
||||
user=user,
|
||||
url=reverse("com:mailing_admin"),
|
||||
|
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.timezone import localdate, localtime, now
|
||||
from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
|
||||
from club.forms import MailingForm
|
||||
from club.models import Club, Mailing, Membership
|
||||
@ -164,6 +165,27 @@ class TestMembershipQuerySet(TestClub):
|
||||
assert new_mem != "not_member"
|
||||
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):
|
||||
"""Test that the `delete` queryset properly invalidate cache."""
|
||||
mem_skia = self.skia.memberships.get(club=self.club)
|
||||
@ -182,6 +204,19 @@ class TestMembershipQuerySet(TestClub):
|
||||
)
|
||||
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):
|
||||
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.role == role
|
||||
assert membership.club.get_membership_for(user) == membership
|
||||
member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||
board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
||||
assert user.is_in_group(name=member_group)
|
||||
assert user.is_in_group(name=board_group)
|
||||
assert user.is_in_group(pk=self.club.members_group_id)
|
||||
assert user.is_in_group(pk=self.club.board_group_id)
|
||||
|
||||
def assert_membership_ended_today(self, user: User):
|
||||
"""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 membership == new_mem
|
||||
|
||||
def test_delete_remove_from_meta_group(self):
|
||||
"""Test that when a club is deleted, all its members are removed from the
|
||||
associated metagroup.
|
||||
"""
|
||||
memberships = self.club.members.select_related("user")
|
||||
users = [membership.user for membership in memberships]
|
||||
meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||
def test_remove_from_club_group(self):
|
||||
"""Test that when a membership ends, the user is removed from club groups."""
|
||||
user = baker.make(User)
|
||||
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
||||
assert user.groups.contains(self.club.members_group)
|
||||
assert user.groups.contains(self.club.board_group)
|
||||
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()
|
||||
for user in users:
|
||||
assert not user.is_in_group(name=meta_group)
|
||||
def test_add_to_club_group(self):
|
||||
"""Test that when a membership begins, the user is added to the club 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):
|
||||
"""Test that when a membership begins, the user is added to the meta group."""
|
||||
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
||||
assert not self.subscriber.is_in_group(name=group_members)
|
||||
assert not self.subscriber.is_in_group(name=board_members)
|
||||
Membership.objects.create(club=self.club, user=self.subscriber, role=3)
|
||||
assert self.subscriber.is_in_group(name=group_members)
|
||||
assert self.subscriber.is_in_group(name=board_members)
|
||||
|
||||
def test_remove_from_meta_group(self):
|
||||
"""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_change_position_in_club(self):
|
||||
"""Test that when moving from board to members, club group change"""
|
||||
membership = 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)
|
||||
membership.role = 1
|
||||
membership.save()
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
|
||||
def test_club_owner(self):
|
||||
"""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()
|
||||
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):
|
||||
"""Perform validation tests for MailingForm."""
|
||||
|
@ -71,14 +71,13 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
return self.object.get_display_name()
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
tab_list = []
|
||||
tab_list.append(
|
||||
tab_list = [
|
||||
{
|
||||
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
|
||||
"slug": "infos",
|
||||
"name": _("Infos"),
|
||||
}
|
||||
)
|
||||
]
|
||||
if self.request.user.can_view(self.object):
|
||||
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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
@ -34,7 +35,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Club
|
||||
from core.models import Notification, Preferences, RealGroup, User
|
||||
from core.models import Notification, Preferences, User
|
||||
|
||||
|
||||
class Sith(models.Model):
|
||||
@ -62,16 +63,31 @@ NEWS_TYPES = [
|
||||
|
||||
|
||||
class News(models.Model):
|
||||
"""The news class."""
|
||||
"""News about club events."""
|
||||
|
||||
title = models.CharField(_("title"), max_length=64)
|
||||
summary = models.TextField(_("summary"))
|
||||
content = models.TextField(_("content"))
|
||||
summary = models.TextField(
|
||||
_("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"), max_length=16, choices=NEWS_TYPES, default="EVENT"
|
||||
)
|
||||
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(
|
||||
User,
|
||||
@ -85,7 +101,7 @@ class News(models.Model):
|
||||
related_name="moderated_news",
|
||||
verbose_name=_("moderator"),
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
@ -93,17 +109,15 @@ class News(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
for u in (
|
||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
.first()
|
||||
.users.all()
|
||||
for user in User.objects.filter(
|
||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
|
||||
):
|
||||
Notification(
|
||||
user=u,
|
||||
Notification.objects.create(
|
||||
user=user,
|
||||
url=reverse("com:news_admin_list"),
|
||||
type="NEWS_MODERATION",
|
||||
param="1",
|
||||
).save()
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("com:news_detail", kwargs={"news_id": self.id})
|
||||
@ -321,16 +335,14 @@ class Poster(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.is_moderated:
|
||||
for u in (
|
||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
.first()
|
||||
.users.all()
|
||||
for user in User.objects.filter(
|
||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
|
||||
):
|
||||
Notification(
|
||||
user=u,
|
||||
Notification.objects.create(
|
||||
user=user,
|
||||
url=reverse("com:poster_moderate_list"),
|
||||
type="POSTER_MODERATION",
|
||||
).save()
|
||||
)
|
||||
return super().save(*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.base import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from com.calendar import IcsCalendar
|
||||
from com.models import News
|
||||
|
||||
|
||||
@receiver(post_save, 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) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
|
||||
<section id="news_details">
|
||||
|
@ -34,24 +34,70 @@
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors() }}
|
||||
{{ 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>
|
||||
<li>{% trans %}Notice: Information, election result - no 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>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
|
||||
<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>
|
||||
{{ form.type }}</p>
|
||||
<p class="date">{{ form.start_date.errors }}<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label> {{ form.start_date }}</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 }}">{{ form.title.label }}</label> {{ form.title }}</p>
|
||||
<p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
|
||||
<p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
|
||||
<p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
|
||||
{{ form.type }}
|
||||
</p>
|
||||
<p class="date">
|
||||
{{ form.start_date.errors }}
|
||||
<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
|
||||
{{ form.start_date }}
|
||||
</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 %}
|
||||
<p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
|
||||
{{ form.automoderation }}</p>
|
||||
<p>
|
||||
{{ form.automoderation.errors }}
|
||||
<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
|
||||
{{ form.automoderation }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
|
||||
@ -62,15 +108,16 @@
|
||||
{{ super() }}
|
||||
<script>
|
||||
$(function () {
|
||||
var type = $('input[name=type]');
|
||||
var dates = $('.date');
|
||||
var until = $('.until');
|
||||
let type = $('input[name=type]');
|
||||
let dates = $('.date');
|
||||
let until = $('.until');
|
||||
|
||||
function update_targets() {
|
||||
type_checked = $('input[name=type]:checked');
|
||||
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();
|
||||
until.hide();
|
||||
} else if (type_checked.val() == "WEEKLY") {
|
||||
} else if (type_checked.val() === "WEEKLY") {
|
||||
dates.show();
|
||||
until.show();
|
||||
} else {
|
||||
@ -78,6 +125,7 @@
|
||||
until.hide();
|
||||
}
|
||||
}
|
||||
|
||||
update_targets();
|
||||
type.change(update_targets);
|
||||
});
|
||||
|
@ -5,6 +5,15 @@
|
||||
{% trans %}News{% endtrans %}
|
||||
{% 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 %}
|
||||
{% if user.is_com_admin %}
|
||||
<div id="news_admin">
|
||||
@ -83,84 +92,78 @@
|
||||
</div>
|
||||
{% 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>
|
||||
<iframe
|
||||
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>
|
||||
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="right_column" class="news_column">
|
||||
<div id="agenda">
|
||||
<div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
|
||||
<div id="agenda_content">
|
||||
{% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
|
||||
news__is_moderated=True, news__type__in=["WEEKLY",
|
||||
"EVENT"]).order_by('start_date', 'end_date') %}
|
||||
<div class="agenda_item">
|
||||
<div class="agenda_date">
|
||||
<strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
|
||||
</div>
|
||||
<div class="agenda_time">
|
||||
<span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
|
||||
<span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
|
||||
<a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
|
||||
</div>
|
||||
<div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div id="right_column">
|
||||
<div id="links">
|
||||
<h3>{% trans %}Links{% endtrans %}</h3>
|
||||
<div id="links_content">
|
||||
<h4>{% trans %}Our services{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<i class="fa-solid fa-graduation-cap fa-xl"></i>
|
||||
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
||||
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<br>
|
||||
<h4>{% trans %}Social media{% endtrans %}</h4>
|
||||
<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/XK9WfPsUFm">{% 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 id="birthdays">
|
||||
<div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
|
||||
<h3>{% trans %}Birthdays{% endtrans %}</h3>
|
||||
<div id="birthdays_content">
|
||||
{% if user.is_subscribed %}
|
||||
{# Cache request for 1 hour #}
|
||||
{% cache 3600 "birthdays" %}
|
||||
{%- if user.was_subscribed -%}
|
||||
<ul class="birthdays_year">
|
||||
{% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
|
||||
{%- for year, users in birthdays -%}
|
||||
<li>
|
||||
{% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
|
||||
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
|
||||
<ul>
|
||||
{% for u in birthdays.filter(date_of_birth__year=d.year) %}
|
||||
{%- for u in users -%}
|
||||
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
|
||||
{% endfor %}
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
{% endcache %}
|
||||
{% else %}
|
||||
<p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
|
||||
{% endif %}
|
||||
{%- else -%}
|
||||
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
@ -10,6 +10,10 @@
|
||||
{% trans %}Poster{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="poster_list">
|
||||
|
||||
|
@ -5,6 +5,10 @@
|
||||
<script src="{{ static('com/js/poster_list.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="poster_list">
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<title>{% trans %}Slideshow{% endtrans %}</title>
|
||||
<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>
|
||||
</head>
|
||||
<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 com.models import News, Poster, Sith, Weekmail, WeekmailArticle
|
||||
from core.models import AnonymousUser, RealGroup, User
|
||||
from core.models import AnonymousUser, Group, User
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@ -49,9 +49,7 @@ class TestCom(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.com_group = RealGroup.objects.filter(
|
||||
id=settings.SITH_GROUP_COM_ADMIN_ID
|
||||
).first()
|
||||
cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
cls.skia.groups.set([cls.com_group])
|
||||
|
||||
def setUp(self):
|
||||
@ -99,9 +97,7 @@ class TestCom(TestCase):
|
||||
response = self.client.get(reverse("core:index"))
|
||||
self.assertContains(
|
||||
response,
|
||||
text=html.escape(
|
||||
_("You need an up to date subscription to access this content")
|
||||
),
|
||||
text=html.escape(_("You need to subscribe to access this content")),
|
||||
)
|
||||
|
||||
def test_birthday_subscibed_user(self):
|
||||
@ -109,9 +105,16 @@ class TestCom(TestCase):
|
||||
|
||||
self.assertNotContains(
|
||||
response,
|
||||
text=html.escape(
|
||||
_("You need an up to date subscription to access this content")
|
||||
),
|
||||
text=html.escape(_("You need to subscribe 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")),
|
||||
)
|
||||
|
||||
|
67
com/views.py
67
com/views.py
@ -21,14 +21,14 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
from smtplib import SMTPRecipientsRefused
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
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.http import HttpResponseRedirect
|
||||
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 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 (
|
||||
CanCreateMixin,
|
||||
CanEditMixin,
|
||||
@ -223,15 +223,13 @@ class NewsForm(forms.ModelForm):
|
||||
):
|
||||
self.add_error(
|
||||
"end_date",
|
||||
ValidationError(
|
||||
_("You crazy? You can not finish an event before starting it.")
|
||||
),
|
||||
ValidationError(_("An event cannot end before its beginning.")),
|
||||
)
|
||||
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
|
||||
self.add_error("until", ValidationError(_("This field is required.")))
|
||||
return self.cleaned_data
|
||||
|
||||
def save(self):
|
||||
def save(self, *args, **kwargs):
|
||||
ret = super().save()
|
||||
self.instance.dates.all().delete()
|
||||
if self.instance.type == "EVENT" or self.instance.type == "CALL":
|
||||
@ -280,21 +278,18 @@ class NewsEditView(CanEditMixin, UpdateView):
|
||||
else:
|
||||
self.object.is_moderated = False
|
||||
self.object.save()
|
||||
for u in (
|
||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
.first()
|
||||
.users.all()
|
||||
unread_notif_subquery = Notification.objects.filter(
|
||||
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
|
||||
)
|
||||
for user in User.objects.filter(
|
||||
~Exists(unread_notif_subquery),
|
||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||
):
|
||||
if not u.notifications.filter(
|
||||
type="NEWS_MODERATION", viewed=False
|
||||
).exists():
|
||||
Notification(
|
||||
user=u,
|
||||
url=reverse(
|
||||
"com:news_detail", kwargs={"news_id": self.object.id}
|
||||
),
|
||||
Notification.objects.create(
|
||||
user=user,
|
||||
url=self.object.get_absolute_url(),
|
||||
type="NEWS_MODERATION",
|
||||
).save()
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
@ -325,19 +320,18 @@ class NewsCreateView(CanCreateMixin, CreateView):
|
||||
self.object.is_moderated = True
|
||||
self.object.save()
|
||||
else:
|
||||
for u in (
|
||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
.first()
|
||||
.users.all()
|
||||
unread_notif_subquery = Notification.objects.filter(
|
||||
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
|
||||
)
|
||||
for user in User.objects.filter(
|
||||
~Exists(unread_notif_subquery),
|
||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||
):
|
||||
if not u.notifications.filter(
|
||||
type="NEWS_MODERATION", viewed=False
|
||||
).exists():
|
||||
Notification(
|
||||
user=u,
|
||||
Notification.objects.create(
|
||||
user=user,
|
||||
url=reverse("com:news_admin_list"),
|
||||
type="NEWS_MODERATION",
|
||||
).save()
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
@ -380,13 +374,14 @@ class NewsListView(CanViewMixin, ListView):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["NewsDate"] = NewsDate
|
||||
kwargs["timedelta"] = timedelta
|
||||
kwargs["birthdays"] = (
|
||||
kwargs["birthdays"] = itertools.groupby(
|
||||
User.objects.filter(
|
||||
date_of_birth__month=localdate().month,
|
||||
date_of_birth__day=localdate().day,
|
||||
)
|
||||
.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
|
||||
|
||||
@ -690,8 +685,12 @@ class PosterEditBaseView(UpdateView):
|
||||
|
||||
def get_initial(self):
|
||||
return {
|
||||
"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"),
|
||||
"date_begin": self.object.date_begin.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):
|
||||
|
@ -15,17 +15,32 @@
|
||||
|
||||
from django.contrib import admin
|
||||
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.register(Group)
|
||||
class GroupAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "description", "is_meta")
|
||||
list_filter = ("is_meta",)
|
||||
list_display = ("name", "description", "is_manually_manageable")
|
||||
list_filter = ("is_manually_manageable",)
|
||||
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)
|
||||
@ -37,10 +52,24 @@ class UserAdmin(admin.ModelAdmin):
|
||||
"profile_pict",
|
||||
"avatar_pict",
|
||||
"scrub_pict",
|
||||
"user_permissions",
|
||||
"groups",
|
||||
)
|
||||
inlines = (UserBanInline,)
|
||||
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)
|
||||
class PageAdmin(admin.ModelAdmin):
|
||||
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 club.models import Membership
|
||||
from core.models import User
|
||||
from core.models import Group, User
|
||||
from subscription.models import Subscription
|
||||
|
||||
active_subscription = Recipe(
|
||||
@ -60,5 +60,6 @@ board_user = Recipe(
|
||||
first_name="AE",
|
||||
last_name=seq("member "),
|
||||
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."""
|
||||
|
@ -13,12 +13,42 @@
|
||||
#
|
||||
#
|
||||
|
||||
import hashlib
|
||||
import multiprocessing
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
|
||||
import tomli
|
||||
from django.core.management.base import BaseCommand, CommandParser
|
||||
import urllib3
|
||||
from django.core.management.base import BaseCommand, CommandParser, OutputWrapper
|
||||
from urllib3.response import HTTPException
|
||||
|
||||
|
||||
@dataclass
|
||||
class XapianSpec:
|
||||
version: str
|
||||
core_sha1: str
|
||||
bindings_sha1: str
|
||||
|
||||
@classmethod
|
||||
def from_pyproject(cls) -> Self:
|
||||
with open(
|
||||
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
|
||||
) as f:
|
||||
pyproject = tomli.load(f)
|
||||
spec = pyproject["tool"]["xapian"]
|
||||
return cls(
|
||||
version=spec["version"],
|
||||
core_sha1=spec["core-sha1"],
|
||||
bindings_sha1=spec["bindings-sha1"],
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -39,13 +69,6 @@ class Command(BaseCommand):
|
||||
return None
|
||||
return xapian.version_string()
|
||||
|
||||
def _desired_version(self) -> str:
|
||||
with open(
|
||||
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
|
||||
) as f:
|
||||
pyproject = tomli.load(f)
|
||||
return pyproject["tool"]["xapian"]["version"]
|
||||
|
||||
def handle(self, *args, force: bool, **options):
|
||||
if not os.environ.get("VIRTUAL_ENV", None):
|
||||
self.stdout.write(
|
||||
@ -53,20 +76,185 @@ class Command(BaseCommand):
|
||||
)
|
||||
return
|
||||
|
||||
desired = self._desired_version()
|
||||
if desired == self._current_version():
|
||||
desired = XapianSpec.from_pyproject()
|
||||
if desired.version == self._current_version():
|
||||
if not force:
|
||||
self.stdout.write(
|
||||
f"Version {desired} is already installed, use --force to re-install"
|
||||
f"Version {desired.version} is already installed, use --force to re-install"
|
||||
)
|
||||
return
|
||||
self.stdout.write(f"Version {desired} is already installed, re-installing")
|
||||
self.stdout.write(
|
||||
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
|
||||
)
|
||||
subprocess.run(
|
||||
[str(Path(__file__).parent / "install_xapian.sh"), desired],
|
||||
env=dict(os.environ),
|
||||
check=True,
|
||||
f"Version {desired.version} is already installed, re-installing"
|
||||
)
|
||||
XapianInstaller(desired, self.stdout, self.stderr).run()
|
||||
self.stdout.write("Installation success")
|
||||
|
||||
|
||||
class XapianInstaller:
|
||||
def __init__(
|
||||
self,
|
||||
spec: XapianSpec,
|
||||
stdout: OutputWrapper,
|
||||
stderr: OutputWrapper,
|
||||
):
|
||||
self._version = spec.version
|
||||
self._core_sha1 = spec.core_sha1
|
||||
self._bindings_sha1 = spec.bindings_sha1
|
||||
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
self._virtual_env = os.environ.get("VIRTUAL_ENV", None)
|
||||
|
||||
if not self._virtual_env:
|
||||
raise RuntimeError("You are not inside a virtual environment")
|
||||
self._virtual_env = Path(self._virtual_env)
|
||||
|
||||
self._dest_dir = Path(self._virtual_env) / "packages"
|
||||
self._core = f"xapian-core-{self._version}"
|
||||
self._bindings = f"xapian-bindings-{self._version}"
|
||||
|
||||
@property
|
||||
def _is_windows(self) -> bool:
|
||||
return platform.system() == "Windows"
|
||||
|
||||
def _util_download(self, url: str, dest: Path, sha1_hash: str) -> None:
|
||||
resp = urllib3.request("GET", url)
|
||||
if resp.status != 200:
|
||||
raise HTTPException(f"Could not download {url}")
|
||||
if hashlib.sha1(resp.data).hexdigest() != sha1_hash:
|
||||
raise ValueError(f"File downloaded from {url} is compromised")
|
||||
with open(dest, "wb") as f:
|
||||
f.write(resp.data)
|
||||
|
||||
def _setup_env(self):
|
||||
os.environ.update(
|
||||
{
|
||||
"CPATH": "",
|
||||
"LIBRARY_PATH": "",
|
||||
"CFLAGS": "",
|
||||
"LDFLAGS": "",
|
||||
"CCFLAGS": "",
|
||||
"CXXFLAGS": "",
|
||||
"CPPFLAGS": "",
|
||||
}
|
||||
)
|
||||
|
||||
def _prepare_dest_folder(self):
|
||||
shutil.rmtree(self._dest_dir, ignore_errors=True)
|
||||
self._dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _download(self):
|
||||
self._stdout.write("Downloading source…")
|
||||
|
||||
core = self._dest_dir / f"{self._core}.tar.xz"
|
||||
bindings = self._dest_dir / f"{self._bindings}.tar.xz"
|
||||
self._util_download(
|
||||
f"https://oligarchy.co.uk/xapian/{self._version}/{self._core}.tar.xz",
|
||||
core,
|
||||
self._core_sha1,
|
||||
)
|
||||
self._util_download(
|
||||
f"https://oligarchy.co.uk/xapian/{self._version}/{self._bindings}.tar.xz",
|
||||
bindings,
|
||||
self._bindings_sha1,
|
||||
)
|
||||
self._stdout.write("Extracting source …")
|
||||
with tarfile.open(core) as tar:
|
||||
tar.extractall(self._dest_dir)
|
||||
with tarfile.open(bindings) as tar:
|
||||
tar.extractall(self._dest_dir)
|
||||
|
||||
os.remove(core)
|
||||
os.remove(bindings)
|
||||
|
||||
def _install(self):
|
||||
self._stdout.write("Installing Xapian-core…")
|
||||
def configure() -> list[str]:
|
||||
if self._is_windows:
|
||||
return ["sh", "configure"]
|
||||
return ["./configure"]
|
||||
def enable_static() -> list[str]:
|
||||
if self._is_windows:
|
||||
return ["--enable-shared", "--disable-static"]
|
||||
return []
|
||||
|
||||
# Make sure that xapian finds the correct executable
|
||||
os.environ["PYTHON3"] = str(Path(sys.executable).as_posix())
|
||||
|
||||
subprocess.run(
|
||||
[*configure(), "--prefix", str(self._virtual_env.as_posix()), *enable_static(),],
|
||||
env=dict(os.environ),
|
||||
cwd=self._dest_dir / self._core,
|
||||
check=False,
|
||||
shell=self._is_windows,
|
||||
).check_returncode()
|
||||
subprocess.run(
|
||||
[
|
||||
"make",
|
||||
"-j",
|
||||
str(multiprocessing.cpu_count()),
|
||||
],
|
||||
env=dict(os.environ),
|
||||
cwd=self._dest_dir / self._core,
|
||||
check=False,
|
||||
shell=self._is_windows,
|
||||
).check_returncode()
|
||||
subprocess.run(
|
||||
["make", "install"],
|
||||
env=dict(os.environ),
|
||||
cwd=self._dest_dir / self._core,
|
||||
check=False,
|
||||
shell=self._is_windows,
|
||||
|
||||
).check_returncode()
|
||||
|
||||
|
||||
self._stdout.write("Installing Xapian-bindings")
|
||||
subprocess.run(
|
||||
[
|
||||
*configure(),
|
||||
"--prefix",
|
||||
str(self._virtual_env.as_posix()),
|
||||
"--with-python3",
|
||||
f"XAPIAN_CONFIG={(self._virtual_env / 'bin'/'xapian-config').as_posix()}",
|
||||
*enable_static(),
|
||||
],
|
||||
env=dict(os.environ),
|
||||
cwd=self._dest_dir / self._bindings,
|
||||
check=False,
|
||||
shell=self._is_windows,
|
||||
).check_returncode()
|
||||
subprocess.run(
|
||||
[
|
||||
"make",
|
||||
"-j",
|
||||
str(multiprocessing.cpu_count()),
|
||||
],
|
||||
env=dict(os.environ),
|
||||
cwd=self._dest_dir / self._bindings,
|
||||
check=False,
|
||||
shell=self._is_windows,
|
||||
).check_returncode()
|
||||
subprocess.run(
|
||||
["make", "install"],
|
||||
env=dict(os.environ),
|
||||
cwd=self._dest_dir / self._bindings,
|
||||
check=False,
|
||||
shell=self._is_windows,
|
||||
).check_returncode()
|
||||
|
||||
def _post_clean(self):
|
||||
shutil.rmtree(self._dest_dir, ignore_errors=True)
|
||||
|
||||
def _test(self):
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", "import xapian"], check=False, shell=self._is_windows,
|
||||
).check_returncode()
|
||||
|
||||
def run(self):
|
||||
self._setup_env()
|
||||
self._prepare_dest_folder()
|
||||
self._download()
|
||||
self._install()
|
||||
self._post_clean()
|
||||
self._test()
|
||||
|
@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
|
||||
# first argument of the script is Xapian version (e.g. 1.2.19)
|
||||
VERSION="$1"
|
||||
|
||||
# Cleanup env vars for auto discovery mechanism
|
||||
export CPATH=
|
||||
export LIBRARY_PATH=
|
||||
export CFLAGS=
|
||||
export LDFLAGS=
|
||||
export CCFLAGS=
|
||||
export CXXFLAGS=
|
||||
export CPPFLAGS=
|
||||
|
||||
# prepare
|
||||
rm -rf "$VIRTUAL_ENV/packages"
|
||||
mkdir -p "$VIRTUAL_ENV/packages" && cd "$VIRTUAL_ENV/packages" || exit 1
|
||||
|
||||
CORE=xapian-core-$VERSION
|
||||
BINDINGS=xapian-bindings-$VERSION
|
||||
|
||||
# download
|
||||
echo "Downloading source..."
|
||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
|
||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
|
||||
|
||||
# extract
|
||||
echo "Extracting source..."
|
||||
tar xf "${CORE}.tar.xz"
|
||||
tar xf "${BINDINGS}.tar.xz"
|
||||
|
||||
# install
|
||||
echo "Installing Xapian-core..."
|
||||
cd "$VIRTUAL_ENV/packages/${CORE}" || exit 1
|
||||
./configure --prefix="$VIRTUAL_ENV" && make -j"$(nproc)" && make install
|
||||
|
||||
PYTHON_FLAG=--with-python3
|
||||
|
||||
echo "Installing Xapian-bindings..."
|
||||
cd "$VIRTUAL_ENV/packages/${BINDINGS}" || exit 1
|
||||
./configure --prefix="$VIRTUAL_ENV" $PYTHON_FLAG XAPIAN_CONFIG="$VIRTUAL_ENV/bin/xapian-config" && make -j"$(nproc)" && make install
|
||||
|
||||
# clean
|
||||
rm -rf "$VIRTUAL_ENV/packages"
|
||||
|
||||
# test
|
||||
python -c "import xapian"
|
@ -23,7 +23,7 @@
|
||||
from datetime import date, timedelta
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, NamedTuple
|
||||
|
||||
from django.conf import settings
|
||||
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.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import Image
|
||||
@ -45,8 +46,9 @@ from accounting.models import (
|
||||
SimplifiedAccountingType,
|
||||
)
|
||||
from club.models import Club, Membership
|
||||
from com.calendar import IcsCalendar
|
||||
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 counter.models import Counter, Product, ProductType, StudentCard
|
||||
from election.models import Candidature, Election, ElectionList, Role
|
||||
@ -56,6 +58,18 @@ from sas.models import Album, PeoplePictureRelation, Picture
|
||||
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):
|
||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||
@ -69,7 +83,7 @@ class Command(BaseCommand):
|
||||
# sqlite doesn't support this operation
|
||||
return
|
||||
sqlcmd = StringIO()
|
||||
call_command("sqlsequencereset", *args, stdout=sqlcmd)
|
||||
call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(sqlcmd.getvalue())
|
||||
|
||||
@ -79,25 +93,8 @@ class Command(BaseCommand):
|
||||
|
||||
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
|
||||
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
|
||||
|
||||
root_group = Group.objects.create(name="Root")
|
||||
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)
|
||||
groups = self._create_groups()
|
||||
self._create_ban_groups()
|
||||
|
||||
root = User.objects.create_superuser(
|
||||
id=0,
|
||||
@ -137,11 +134,10 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
self.reset_index("club")
|
||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
|
||||
self.reset_index("counter")
|
||||
counters = [
|
||||
*[
|
||||
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR")
|
||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS
|
||||
],
|
||||
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
||||
Counter(name="AE", club=main_club, type="OFFICE"),
|
||||
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
||||
@ -149,14 +145,16 @@ class Command(BaseCommand):
|
||||
Counter.objects.bulk_create(counters)
|
||||
bar_groups = []
|
||||
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(
|
||||
Counter.edit_groups.through(counter_id=bar_id, group=group)
|
||||
)
|
||||
Counter.edit_groups.through.objects.bulk_create(bar_groups)
|
||||
self.reset_index("counter")
|
||||
|
||||
subscribers.viewable_files.add(home_root, club_root)
|
||||
groups.subscribers.viewable_files.add(home_root, club_root)
|
||||
|
||||
Weekmail().save()
|
||||
|
||||
@ -261,21 +259,11 @@ class Command(BaseCommand):
|
||||
)
|
||||
User.groups.through.objects.bulk_create(
|
||||
[
|
||||
User.groups.through(
|
||||
realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
|
||||
),
|
||||
User.groups.through(
|
||||
realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
|
||||
),
|
||||
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
|
||||
),
|
||||
User.groups.through(group=groups.counter_admin, user=counter),
|
||||
User.groups.through(group=groups.accounting_admin, user=comptable),
|
||||
User.groups.through(group=groups.com_admin, user=comunity),
|
||||
User.groups.through(group=groups.pedagogy_admin, user=tutu),
|
||||
User.groups.through(group=groups.sas_admin, user=skia),
|
||||
]
|
||||
)
|
||||
for user in richard, sli, krophil, skia:
|
||||
@ -336,7 +324,7 @@ Welcome to the wiki page!
|
||||
content="Fonctionnement de la laverie",
|
||||
)
|
||||
|
||||
public_group.viewable_page.set(
|
||||
groups.public.viewable_page.set(
|
||||
[syntax_page, services_page, index_page, laundry_page]
|
||||
)
|
||||
|
||||
@ -382,46 +370,42 @@ Welcome to the wiki page!
|
||||
parent=main_club,
|
||||
)
|
||||
|
||||
Membership.objects.bulk_create(
|
||||
[
|
||||
Membership(user=skia, club=main_club, role=3),
|
||||
Membership(
|
||||
Membership.objects.create(user=skia, club=main_club, role=3)
|
||||
Membership.objects.create(
|
||||
user=comunity,
|
||||
club=bar_club,
|
||||
start_date=localdate(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
),
|
||||
Membership(
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=sli,
|
||||
club=troll,
|
||||
role=9,
|
||||
description="Padawan Troll",
|
||||
start_date=localdate() - timedelta(days=17),
|
||||
),
|
||||
Membership(
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=krophil,
|
||||
club=troll,
|
||||
role=10,
|
||||
description="Maitre Troll",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
),
|
||||
Membership(
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=skia,
|
||||
club=troll,
|
||||
role=2,
|
||||
description="Grand Ancien Troll",
|
||||
start_date=localdate() - timedelta(days=400),
|
||||
end_date=localdate() - timedelta(days=86),
|
||||
),
|
||||
Membership(
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=richard,
|
||||
club=troll,
|
||||
role=2,
|
||||
description="",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
end_date=localdate() - timedelta(days=100),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
p = ProductType.objects.create(name="Bières bouteilles")
|
||||
@ -513,8 +497,10 @@ Welcome to the wiki page!
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
|
||||
old_subscribers.products.add(cotis, cotis2)
|
||||
groups.subscribers.products.add(
|
||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
||||
)
|
||||
groups.old_subscribers.products.add(cotis, cotis2)
|
||||
|
||||
mde = Counter.objects.get(name="MDE")
|
||||
mde.products.add(barb, cble, cons, dcons)
|
||||
@ -608,7 +594,6 @@ Welcome to the wiki page!
|
||||
)
|
||||
|
||||
# Create an election
|
||||
ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
|
||||
el = Election.objects.create(
|
||||
title="Élection 2017",
|
||||
description="La roue tourne",
|
||||
@ -617,10 +602,10 @@ Welcome to the wiki page!
|
||||
start_date="1942-06-12 10:28:45+01",
|
||||
end_date="7942-06-12 10:28:45+01",
|
||||
)
|
||||
el.view_groups.add(public_group)
|
||||
el.edit_groups.add(ae_board_group)
|
||||
el.candidature_groups.add(subscribers)
|
||||
el.vote_groups.add(subscribers)
|
||||
el.view_groups.add(groups.public)
|
||||
el.edit_groups.add(main_club.board_group)
|
||||
el.candidature_groups.add(groups.subscribers)
|
||||
el.vote_groups.add(groups.subscribers)
|
||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||
listeT = ElectionList.objects.create(title="Troll", election=el)
|
||||
pres = Role.objects.create(
|
||||
@ -755,7 +740,7 @@ Welcome to the wiki page!
|
||||
NewsDate(
|
||||
news=n,
|
||||
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
|
||||
@ -781,8 +766,9 @@ Welcome to the wiki page!
|
||||
]
|
||||
)
|
||||
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(
|
||||
code="PA00",
|
||||
@ -899,3 +885,114 @@ Welcome to the wiki page!
|
||||
start=s.subscription_start,
|
||||
)
|
||||
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 club.models import Club, Membership
|
||||
from core.models import RealGroup, User
|
||||
from core.models import Group, User
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
@ -173,7 +173,8 @@ class Command(BaseCommand):
|
||||
club=club,
|
||||
)
|
||||
)
|
||||
Membership.objects.bulk_create(memberships)
|
||||
memberships = Membership.objects.bulk_create(memberships)
|
||||
Membership._add_club_groups(memberships)
|
||||
|
||||
def create_uvs(self):
|
||||
root = User.objects.get(username="root")
|
||||
@ -225,9 +226,7 @@ class Command(BaseCommand):
|
||||
ae = Club.objects.get(unix_name="ae")
|
||||
other_clubs = random.sample(list(Club.objects.all()), k=3)
|
||||
groups = list(
|
||||
RealGroup.objects.filter(
|
||||
name__in=["Subscribers", "Old subscribers", "Public"]
|
||||
)
|
||||
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
|
||||
)
|
||||
counters = list(
|
||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
|
||||
|
@ -563,14 +563,21 @@ class Migration(migrations.Migration):
|
||||
fields=[],
|
||||
options={"proxy": True},
|
||||
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(
|
||||
name="RealGroup",
|
||||
fields=[],
|
||||
options={"proxy": True},
|
||||
bases=("core.group",),
|
||||
managers=[("objects", core.models.RealGroupManager())],
|
||||
managers=[("objects", django.contrib.auth.models.GroupManager())],
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
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
|
||||
),
|
||||
]
|
318
core/models.py
318
core/models.py
@ -30,26 +30,19 @@ import string
|
||||
import unicodedata
|
||||
from datetime import timedelta
|
||||
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.contrib.auth.models import AbstractBaseUser, UserManager
|
||||
from django.contrib.auth.models import (
|
||||
AnonymousUser as AuthAnonymousUser,
|
||||
)
|
||||
from django.contrib.auth.models import (
|
||||
Group as AuthGroup,
|
||||
)
|
||||
from django.contrib.auth.models import (
|
||||
GroupManager as AuthGroupManager,
|
||||
)
|
||||
from django.contrib.auth.models import AbstractUser, UserManager
|
||||
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
|
||||
from django.contrib.auth.models import Group as AuthGroup
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.core import validators
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.mail import send_mail
|
||||
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.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
@ -64,33 +57,15 @@ if TYPE_CHECKING:
|
||||
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):
|
||||
"""Implement both RealGroups and Meta groups.
|
||||
"""Wrapper around django.auth.Group"""
|
||||
|
||||
Groups are sorted by their is_meta property
|
||||
"""
|
||||
|
||||
#: If False, this is a RealGroup
|
||||
is_meta = models.BooleanField(
|
||||
_("meta group status"),
|
||||
is_manually_manageable = models.BooleanField(
|
||||
_("Is manually manageable"),
|
||||
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.CharField(_("description"), max_length=60)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
description = models.TextField(_("description"))
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("core:group_list")
|
||||
@ -106,65 +81,6 @@ class Group(AuthGroup):
|
||||
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:
|
||||
start_year = settings.SITH_SCHOOL_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:
|
||||
group = Group.objects.filter(name=name).first()
|
||||
if group is not None:
|
||||
cache.set(f"sith_group_{group.id}", group)
|
||||
cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
|
||||
name = group.name.replace(" ", "_")
|
||||
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
|
||||
else:
|
||||
cache.set(f"sith_group_{pk_or_name}", "not_found")
|
||||
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):
|
||||
def filter_inactive(self) -> Self:
|
||||
from counter.models import Refilling, Selling
|
||||
@ -242,7 +180,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
|
||||
pass
|
||||
|
||||
|
||||
class User(AbstractBaseUser):
|
||||
class User(AbstractUser):
|
||||
"""Defines the base user class, useable in every app.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
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)
|
||||
last_name = models.CharField(_("last name"), max_length=64)
|
||||
email = models.EmailField(_("email address"), unique=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)
|
||||
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)
|
||||
is_superuser = models.BooleanField(
|
||||
_("superuser"),
|
||||
default=False,
|
||||
help_text=_("Designates whether this user is a superuser. "),
|
||||
groups = models.ManyToManyField(
|
||||
Group,
|
||||
verbose_name=_("groups"),
|
||||
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(
|
||||
"SithFile",
|
||||
related_name="home_of",
|
||||
@ -401,8 +316,6 @@ class User(AbstractBaseUser):
|
||||
|
||||
objects = CustomUserManager()
|
||||
|
||||
USERNAME_FIELD = "username"
|
||||
|
||||
def __str__(self):
|
||||
return self.get_display_name()
|
||||
|
||||
@ -422,22 +335,23 @@ class User(AbstractBaseUser):
|
||||
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
|
||||
).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
|
||||
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()
|
||||
|
||||
@cached_property
|
||||
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()
|
||||
)
|
||||
return s.exists()
|
||||
).exists()
|
||||
|
||||
@cached_property
|
||||
def account_balance(self):
|
||||
@ -474,18 +388,6 @@ class User(AbstractBaseUser):
|
||||
return self.was_subscribed
|
||||
if group.id == settings.SITH_GROUP_ROOT_ID:
|
||||
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
|
||||
|
||||
@property
|
||||
@ -510,12 +412,11 @@ class User(AbstractBaseUser):
|
||||
return any(g.id == root_id for g in self.cached_groups)
|
||||
|
||||
@cached_property
|
||||
def is_board_member(self):
|
||||
main_club = settings.SITH_MAIN_CLUB["unix_name"]
|
||||
return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
|
||||
def is_board_member(self) -> bool:
|
||||
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
|
||||
|
||||
@cached_property
|
||||
def can_read_subscription_history(self):
|
||||
def can_read_subscription_history(self) -> bool:
|
||||
if self.is_root or self.is_board_member:
|
||||
return True
|
||||
|
||||
@ -529,13 +430,13 @@ class User(AbstractBaseUser):
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def can_create_subscription(self):
|
||||
from club.models import Club
|
||||
|
||||
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
|
||||
if club in self.clubs_with_rights:
|
||||
return True
|
||||
return False
|
||||
def can_create_subscription(self) -> bool:
|
||||
return self.is_root or (
|
||||
self.memberships.board()
|
||||
.ongoing()
|
||||
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def is_launderette_manager(self):
|
||||
@ -550,12 +451,12 @@ class User(AbstractBaseUser):
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def is_banned_alcohol(self):
|
||||
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||
def is_banned_alcohol(self) -> bool:
|
||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
|
||||
|
||||
@cached_property
|
||||
def is_banned_counter(self):
|
||||
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||
def is_banned_counter(self) -> bool:
|
||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
|
||||
|
||||
@cached_property
|
||||
def age(self) -> int:
|
||||
@ -599,11 +500,6 @@ class User(AbstractBaseUser):
|
||||
"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):
|
||||
"""Returns the short name for the user."""
|
||||
if self.nick_name:
|
||||
@ -619,14 +515,6 @@ class User(AbstractBaseUser):
|
||||
return "%s (%s)" % (self.get_full_name(), self.nick_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(
|
||||
self,
|
||||
godfathers_depth: NonNegativeInt = 4,
|
||||
@ -870,6 +758,52 @@ class AnonymousUser(AuthAnonymousUser):
|
||||
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):
|
||||
user = models.OneToOneField(
|
||||
User, related_name="_preferences", on_delete=models.CASCADE
|
||||
@ -982,19 +916,17 @@ class SithFile(models.Model):
|
||||
if copy_rights:
|
||||
self.copy_rights()
|
||||
if self.is_in_sas:
|
||||
for u in (
|
||||
RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
|
||||
.first()
|
||||
.users.all()
|
||||
for user in User.objects.filter(
|
||||
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
|
||||
):
|
||||
Notification(
|
||||
user=u,
|
||||
user=user,
|
||||
url=reverse("sas:moderation"),
|
||||
type="SAS_MODERATION",
|
||||
param="1",
|
||||
).save()
|
||||
|
||||
def is_owned_by(self, user):
|
||||
def is_owned_by(self, user: User) -> bool:
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_root:
|
||||
@ -1009,7 +941,7 @@ class SithFile(models.Model):
|
||||
return True
|
||||
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"):
|
||||
return user.can_view(self.profile_of)
|
||||
if hasattr(self, "avatar_of"):
|
||||
|
@ -4,6 +4,7 @@ from typing import Annotated
|
||||
from annotated_types import MinLen
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from haystack.query import SearchQuerySet
|
||||
from ninja import FilterSchema, ModelSchema, Schema
|
||||
@ -37,13 +38,13 @@ class UserProfileSchema(ModelSchema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_url(obj: User) -> str:
|
||||
return obj.get_absolute_url()
|
||||
return reverse("core:user_profile", kwargs={"user_id": obj.pk})
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_pict(obj: User) -> str:
|
||||
if obj.profile_pict_id is None:
|
||||
return staticfiles_storage.url("core/img/unknown.jpg")
|
||||
return obj.profile_pict.get_download_url()
|
||||
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
|
||||
|
||||
|
||||
class SithFileSchema(ModelSchema):
|
||||
|
@ -1,5 +1,7 @@
|
||||
import sort from "@alpinejs/sort";
|
||||
import Alpine from "alpinejs";
|
||||
|
||||
Alpine.plugin(sort);
|
||||
window.Alpine = Alpine;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
|
@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
||||
remove_button: {
|
||||
title: gettext("Remove"),
|
||||
},
|
||||
// biome-ignore lint/style/useNamingConvention: this is required by the api
|
||||
restore_on_backspace: {},
|
||||
},
|
||||
persist: false,
|
||||
maxItems: this.node.multiple ? this.max : 1,
|
||||
@ -103,6 +105,12 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
||||
export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
||||
protected filter?: (items: TomOption[]) => TomOption[] = null;
|
||||
protected minCharNumberForSearch = 2;
|
||||
/**
|
||||
* A cache of researches that have been made using this input.
|
||||
* For each record, the key is the user's query and the value
|
||||
* is the list of results sent back by the server.
|
||||
*/
|
||||
protected cache = {} as Record<string, TomOption[]>;
|
||||
|
||||
protected abstract valueField: string;
|
||||
protected abstract labelField: string;
|
||||
@ -135,7 +143,13 @@ export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
||||
this.widget.clearOptions();
|
||||
}
|
||||
|
||||
const resp = await this.search(query);
|
||||
// Check in the cache if this query has already been typed
|
||||
// and do an actual HTTP request only if the result isn't cached
|
||||
let resp = this.cache[query];
|
||||
if (!resp) {
|
||||
resp = await this.search(query);
|
||||
this.cache[query] = resp;
|
||||
}
|
||||
|
||||
if (this.filter) {
|
||||
callback(this.filter(resp), []);
|
||||
|
@ -1,3 +1,11 @@
|
||||
import htmx from "htmx.org";
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
||||
event.target.ariaBusy = true;
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:afterRequest", (event) => {
|
||||
event.originalTarget.ariaBusy = null;
|
||||
});
|
||||
|
||||
Object.assign(window, { htmx });
|
||||
|
@ -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
|
||||
// please test this function. A all cost.
|
||||
/**
|
||||
* Load complete dataset from paginated routes.
|
||||
*/
|
||||
export const paginated = async <T>(
|
||||
endpoint: PaginatedEndpoint<T>,
|
||||
options?: PaginatedRequest,
|
||||
) => {
|
||||
): Promise<T[]> => {
|
||||
const maxPerPage = 199;
|
||||
const queryParams = options ?? {};
|
||||
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;
|
@ -6,7 +6,16 @@
|
||||
**/
|
||||
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
|
||||
return (component: CustomElementConstructor) => {
|
||||
try {
|
||||
window.customElements.define(name, component, options);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException) {
|
||||
// biome-ignore lint/suspicious/noConsole: it's handy to troobleshot
|
||||
console.warn(e.message);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -24,9 +24,17 @@ $black-color: hsl(0, 0%, 17%);
|
||||
|
||||
$faceblue: hsl(221, 44%, 41%);
|
||||
$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);
|
||||
|
||||
$background-button-color: hsl(0, 0%, 95%);
|
||||
|
||||
$deepblue: #354a5f;
|
||||
|
||||
@mixin shadow {
|
||||
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
|
||||
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
|
||||
}
|
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;
|
722
core/static/core/forms.scss
Normal file
722
core/static/core/forms.scss
Normal file
@ -0,0 +1,722 @@
|
||||
@import "colors";
|
||||
|
||||
/**
|
||||
* Style related to forms and form inputs
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inputs that are not enclosed in a form element.
|
||||
*/
|
||||
:not(form) {
|
||||
a.button,
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"],
|
||||
input[type="file"] {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
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"] {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
// Input size - used for height/padding calculations
|
||||
--nf-input-size: 1rem;
|
||||
|
||||
--nf-input-font-size: calc(var(--nf-input-size) * 0.875);
|
||||
--nf-small-font-size: calc(var(--nf-input-size) * 0.875);
|
||||
|
||||
// Input
|
||||
--nf-input-color: $text-color;
|
||||
--nf-input-border-radius: 0.25rem;
|
||||
--nf-input-placeholder-color: #929292;
|
||||
--nf-input-border-color: #c0c4c9;
|
||||
--nf-input-border-width: 1px;
|
||||
--nf-input-border-style: solid;
|
||||
--nf-input-border-bottom-width: 2px;
|
||||
--nf-input-focus-border-color: #3b4ce2;
|
||||
--nf-input-background-color: #f3f6f7;
|
||||
|
||||
// Valid/invalid
|
||||
--nf-invalid-input-border-color: var(--nf-input-border-color);
|
||||
--nf-invalid-input-background-color: var(--nf-input-background-color);
|
||||
--nf-invalid-input-color: var(--nf-input-color);
|
||||
--nf-valid-input-border-color: var(--nf-input-border-color);
|
||||
--nf-valid-input-background-color: var(--nf-input-background-color);
|
||||
--nf-valid-input-color: inherit;
|
||||
--nf-invalid-input-border-bottom-color: red;
|
||||
--nf-valid-input-border-bottom-color: green;
|
||||
|
||||
// Label variables
|
||||
--nf-label-font-size: var(--nf-small-font-size);
|
||||
--nf-label-color: #374151;
|
||||
--nf-label-font-weight: 500;
|
||||
|
||||
// Slider variables
|
||||
--nf-slider-track-background: #dfdfdf;
|
||||
--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);
|
||||
|
||||
display: block;
|
||||
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;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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="month"],
|
||||
input[type="search"],
|
||||
textarea,
|
||||
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 {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@ $hovered-red-text-color: #ff4d4d;
|
||||
gap: 10px;
|
||||
|
||||
> a {
|
||||
color: $text-color;
|
||||
color: $text-color!important;
|
||||
}
|
||||
|
||||
&:hover>a {
|
||||
@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d;
|
||||
}
|
||||
|
||||
>input[type=text] {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
border: unset;
|
||||
height: 35px;
|
||||
border-radius: 5px;
|
||||
font-size: .9em;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,5 @@
|
||||
@import "core/static/core/colors";
|
||||
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@ -69,7 +71,7 @@ main {
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f2f2f2;
|
||||
background-color: $primary-neutral-light-color;
|
||||
|
||||
> span {
|
||||
font-size: small;
|
||||
|
@ -1,26 +1,9 @@
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.title {
|
||||
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 {
|
||||
&-visible {
|
||||
display: flex;
|
||||
@ -87,10 +70,6 @@
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
> i {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
> p {
|
||||
text-align: left !important;
|
||||
width: 100% !important;
|
||||
@ -107,16 +86,6 @@
|
||||
> div {
|
||||
max-width: 100%;
|
||||
|
||||
> input {
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
> button {
|
||||
min-width: 30%;
|
||||
}
|
||||
|
||||
@media (min-width: 750px) {
|
||||
height: auto;
|
||||
align-items: center;
|
||||
@ -124,8 +93,8 @@
|
||||
overflow: hidden;
|
||||
|
||||
> input {
|
||||
width: 70%;
|
||||
font-size: .6em;
|
||||
|
||||
&::file-selector-button {
|
||||
height: 30px;
|
||||
}
|
||||
@ -181,45 +150,22 @@
|
||||
}
|
||||
|
||||
&-content {
|
||||
|
||||
> * {
|
||||
box-sizing: border-box;
|
||||
text-align: left !important;
|
||||
line-height: 40px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
|
||||
> * {
|
||||
text-align: left !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
>textarea {
|
||||
height: 120px;
|
||||
min-height: 40px;
|
||||
min-width: 300px;
|
||||
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;
|
||||
textarea {
|
||||
height: 7rem;
|
||||
}
|
||||
.final-actions {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
@ -108,7 +108,8 @@
|
||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||
</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 %}
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
@ -60,13 +60,18 @@
|
||||
{% endif %}
|
||||
{% if user.date_of_birth %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if user.promo and user.promo_has_logo() %}
|
||||
<div class="user_mini_profile_promo">
|
||||
<img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" />
|
||||
<img
|
||||
src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
|
||||
title="Promo {{ user.promo }}"
|
||||
alt="Promo {{ user.promo }}"
|
||||
class="promo_pict"
|
||||
/>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -74,8 +79,11 @@
|
||||
{% if user.profile_pict %}
|
||||
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
|
||||
{% else %}
|
||||
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
|
||||
title="{% trans %}Profile{% endtrans %}" />
|
||||
<img
|
||||
src="{{ static('core/img/unknown.jpg') }}"
|
||||
alt="{% trans %}Profile{% endtrans %}"
|
||||
title="{% trans %}Profile{% endtrans %}"
|
||||
/>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -132,7 +140,7 @@
|
||||
nb_page (str): call to a javascript function or variable returning
|
||||
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,
|
||||
clicking on the pagination buttons could submit the picture management form
|
||||
and reload the page #}
|
||||
@ -170,12 +178,12 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_htmx(current_page, paginator) %}
|
||||
{# Add pagination buttons for pages without Alpine but supporting framgents.
|
||||
{# Add pagination buttons for pages without Alpine but supporting fragments.
|
||||
|
||||
This must be coupled with a view that handles pagination
|
||||
with the Django Paginator object and supports framgents.
|
||||
with the Django Paginator object and supports fragments.
|
||||
|
||||
The relpaced fragment will be #content so make sure you are calling this macro inside your content block.
|
||||
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
|
||||
|
||||
Parameters:
|
||||
current_page (django.core.paginator.Page): the current page object
|
||||
@ -247,9 +255,9 @@
|
||||
{% macro select_all_checkbox(form_id) %}
|
||||
<script type="text/javascript">
|
||||
function checkbox_{{form_id}}(value) {
|
||||
list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
||||
for (let element of list){
|
||||
if (element.type == "checkbox"){
|
||||
const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
||||
for (let element of inputs){
|
||||
if (element.type === "checkbox"){
|
||||
element.checked = value;
|
||||
}
|
||||
}
|
||||
@ -258,3 +266,65 @@
|
||||
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
|
||||
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro tabs(tab_list, attrs = "") %}
|
||||
{# Tab component
|
||||
|
||||
Parameters:
|
||||
tab_list: list[tuple[str, str]] The list of tabs to display.
|
||||
Each element of the list is a tuple which first element
|
||||
is the title of the tab and the second element its content
|
||||
attrs: str Additional attributes to put on the enclosing div
|
||||
|
||||
Example:
|
||||
A basic usage would be as follow :
|
||||
|
||||
{{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
|
||||
|
||||
If you want to display more complex logic, you can define macros
|
||||
and use those macros in parameters :
|
||||
|
||||
{{ tabs([("title", my_macro())]) }}
|
||||
|
||||
It's also possible to get and set the currently selected tab using Alpine.
|
||||
Here, the title of the currently selected tab will be displayed.
|
||||
Moreover, on page load, the tab will be opened on "tab 2".
|
||||
|
||||
<div x-data="{current_tab: 'tab 2'}">
|
||||
<p x-text="current_tab"></p>
|
||||
{{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
|
||||
</div>
|
||||
|
||||
If you want to have translated tab titles, you can enclose the macro call
|
||||
in a with block :
|
||||
|
||||
{% with title=_("title"), content=_("Content") %}
|
||||
{{ tabs([(tab1, content)]) }}
|
||||
{% endwith %}
|
||||
#}
|
||||
<div
|
||||
class="tabs shadow"
|
||||
x-data="{selected: '{{ tab_list[0][0] }}'}"
|
||||
x-modelable="selected"
|
||||
{{ attrs }}
|
||||
>
|
||||
<div class="tab-headers">
|
||||
{% for title, _ in tab_list %}
|
||||
<button
|
||||
class="tab-header clickable"
|
||||
:class="{active: selected === '{{ title }}'}"
|
||||
@click="selected = '{{ title }}'"
|
||||
>
|
||||
{{ title }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
{% for title, content in tab_list %}
|
||||
<section x-show="selected === '{{ title }}'">
|
||||
{{ content }}
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
@ -3,17 +3,18 @@
|
||||
{% macro page_history(page) %}
|
||||
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
|
||||
<ul>
|
||||
{% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
|
||||
{% if loop.index < 2 %}
|
||||
<li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
|
||||
{{ user_profile_link(page.revisions.last().author) }} -
|
||||
{{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
|
||||
{% set page_name = page.get_full_name() %}
|
||||
{%- for rev in page.revisions.order_by("-date").select_related("author") -%}
|
||||
<li>
|
||||
{% if loop.first %}
|
||||
<a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
|
||||
{% else %}
|
||||
<li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
|
||||
{{ user_profile_link(r.author) }} -
|
||||
{{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
|
||||
<a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ user_profile_link(rev.author) }} -
|
||||
{{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
{% 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 %}
|
||||
|
||||
|
||||
|
@ -63,9 +63,7 @@
|
||||
{%- trans -%}Delete{%- endtrans -%}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
{{ form[field_name].label }}
|
||||
</p>
|
||||
{{ form[field_name].label_tag() }}
|
||||
{{ form[field_name].errors }}
|
||||
{%- else -%}
|
||||
<em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
|
||||
@ -118,9 +116,7 @@
|
||||
{# All fields #}
|
||||
<div class="profile-fields">
|
||||
{%- 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 -%}
|
||||
|
||||
@ -156,6 +152,7 @@
|
||||
{{ form.is_subscriber_viewable }}
|
||||
{{ form.is_subscriber_viewable.label }}
|
||||
</div>
|
||||
<div class="final-actions">
|
||||
|
||||
{%- if form.instance == user -%}
|
||||
<p>
|
||||
@ -172,6 +169,7 @@
|
||||
<p>
|
||||
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p>
|
||||
|
@ -28,42 +28,20 @@
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
<p>{% trans trombi=user.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
|
||||
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
|
||||
<br />
|
||||
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if profile.customer %}
|
||||
<h3>{% trans %}Student cards{% endtrans %}</h3>
|
||||
|
||||
{% if profile.customer.student_cards.exists() %}
|
||||
<ul class="student-cards">
|
||||
{% for card in profile.customer.student_cards.all() %}
|
||||
<li>
|
||||
{{ card.uid }}
|
||||
-
|
||||
<a href="{{ url('counter:delete_student_card', customer_id=profile.customer.pk, card_id=card.id) }}">
|
||||
{% trans %}Delete{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<em class="no-cards">{% trans %}No student card registered.{% endtrans %}</em>
|
||||
{% if student_card_fragment %}
|
||||
<h3>{% trans %}Student card{% endtrans %}</h3>
|
||||
{{ student_card_fragment }}
|
||||
<p class="justify">
|
||||
{% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually
|
||||
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="form form-cards" action="{{ url('counter:add_student_card', customer_id=profile.customer.pk) }}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
{{ student_card_form.as_p() }}
|
||||
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -23,6 +23,9 @@
|
||||
<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>
|
||||
{% 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 %}
|
||||
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
@ -52,7 +55,7 @@
|
||||
%}
|
||||
<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: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:invoices_call') }}">{% trans %}Invoices call{% 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 pytest
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.mail import EmailMessage
|
||||
@ -30,7 +31,7 @@ from model_bakery import baker
|
||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||
|
||||
from antispam.models import ToxicDomain
|
||||
from club.models import Membership
|
||||
from club.models import Club, Membership
|
||||
from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Group, Page, User
|
||||
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)
|
||||
|
||||
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()))
|
||||
|
||||
def test_register_fail_with_not_existing_email(
|
||||
@ -143,7 +146,7 @@ class TestUserRegistration:
|
||||
class TestUserLogin:
|
||||
@pytest.fixture()
|
||||
def user(self) -> User:
|
||||
return User.objects.first()
|
||||
return baker.make(User, password=make_password("plop"))
|
||||
|
||||
def test_login_fail(self, client, user):
|
||||
"""Should not login a user correctly."""
|
||||
@ -347,56 +350,35 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
from club.models import Club
|
||||
|
||||
cls.root_group = Group.objects.get(name="Root")
|
||||
cls.public = Group.objects.get(name="Public")
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.toto = User.objects.create(
|
||||
username="toto", first_name="a", last_name="b", email="a.b@toto.fr"
|
||||
)
|
||||
cls.public_group = Group.objects.get(name="Public")
|
||||
cls.public_user = baker.make(User)
|
||||
cls.subscribers = Group.objects.get(name="Subscribers")
|
||||
cls.old_subscribers = Group.objects.get(name="Old subscribers")
|
||||
cls.accounting_admin = Group.objects.get(name="Accounting admin")
|
||||
cls.com_admin = Group.objects.get(name="Communication 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.club = Club.objects.create(
|
||||
name="Fake Club",
|
||||
unix_name="fake-club",
|
||||
address="Fake address",
|
||||
)
|
||||
cls.club = baker.make(Club)
|
||||
cls.main_club = Club.objects.get(id=1)
|
||||
|
||||
def assert_in_public_group(self, user):
|
||||
assert user.is_in_group(pk=self.public.id)
|
||||
assert user.is_in_group(name=self.public.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
|
||||
assert user.is_in_group(pk=self.public_group.id)
|
||||
assert user.is_in_group(name=self.public_group.name)
|
||||
|
||||
def assert_only_in_public_group(self, user):
|
||||
self.assert_in_public_group(user)
|
||||
for group in (
|
||||
self.root_group,
|
||||
self.banned_counters,
|
||||
self.accounting_admin,
|
||||
self.sas_admin,
|
||||
self.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(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):
|
||||
"""Test that anonymous users are only in the public group."""
|
||||
@ -405,80 +387,80 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
def test_not_subscribed_user(self):
|
||||
"""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):
|
||||
"""Test that when neither the pk nor the name argument is given,
|
||||
the function raises a ValueError.
|
||||
"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.toto.is_in_group()
|
||||
self.public_user.is_in_group()
|
||||
|
||||
def test_number_queries(self):
|
||||
"""Test that the number of db queries is stable
|
||||
and that less queries are made when making a new call.
|
||||
"""
|
||||
# make sure Skia is in at least one group
|
||||
self.skia.groups.add(Group.objects.first().pk)
|
||||
skia_groups = self.skia.groups.all()
|
||||
group_in = baker.make(Group)
|
||||
self.public_user.groups.add(group_in)
|
||||
|
||||
group_in = skia_groups.first()
|
||||
cache.clear()
|
||||
# Test when the user is in the group
|
||||
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):
|
||||
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 = Group.objects.exclude(pk__in=ids).first()
|
||||
group_not_in = baker.make(Group)
|
||||
cache.clear()
|
||||
# Test when the user is not in the group
|
||||
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):
|
||||
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):
|
||||
"""Test that when the membership of a user end,
|
||||
the cache is properly invalidated.
|
||||
"""
|
||||
membership = Membership.objects.create(
|
||||
club=self.club, user=self.toto, end_date=None
|
||||
)
|
||||
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||
membership = baker.make(Membership, club=self.club, user=self.public_user)
|
||||
cache.clear()
|
||||
assert self.toto.is_in_group(name=meta_groups_members) is True
|
||||
assert membership == cache.get(f"membership_{self.club.id}_{self.toto.id}")
|
||||
self.club.get_membership_for(self.public_user) # this should populate the cache
|
||||
assert membership == cache.get(
|
||||
f"membership_{self.club.id}_{self.public_user.id}"
|
||||
)
|
||||
membership.end_date = now() - timedelta(minutes=5)
|
||||
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 self.toto.is_in_group(name=meta_groups_members) is False
|
||||
|
||||
def test_cache_properly_cleared_group(self):
|
||||
"""Test that when a user is removed from a group,
|
||||
the is_in_group_method return False when calling it again.
|
||||
"""
|
||||
# testing with pk
|
||||
self.toto.groups.add(self.com_admin.pk)
|
||||
assert self.toto.is_in_group(pk=self.com_admin.pk) is True
|
||||
self.public_user.groups.add(self.com_admin.pk)
|
||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
|
||||
|
||||
self.toto.groups.remove(self.com_admin.pk)
|
||||
assert self.toto.is_in_group(pk=self.com_admin.pk) is False
|
||||
self.public_user.groups.remove(self.com_admin.pk)
|
||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
|
||||
|
||||
# testing with name
|
||||
self.toto.groups.add(self.sas_admin.pk)
|
||||
assert self.toto.is_in_group(name="SAS admin") is True
|
||||
self.public_user.groups.add(self.sas_admin.pk)
|
||||
assert self.public_user.is_in_group(name="SAS admin") is True
|
||||
|
||||
self.toto.groups.remove(self.sas_admin.pk)
|
||||
assert self.toto.is_in_group(name="SAS admin") is False
|
||||
self.public_user.groups.remove(self.sas_admin.pk)
|
||||
assert self.public_user.is_in_group(name="SAS admin") is False
|
||||
|
||||
def test_not_existing_group(self):
|
||||
"""Test that searching for a not existing group
|
||||
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):
|
||||
|
@ -14,7 +14,7 @@ from PIL import Image
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
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 sith import settings
|
||||
|
||||
@ -26,12 +26,10 @@ class TestImageAccess:
|
||||
[
|
||||
lambda: baker.make(User, is_superuser=True),
|
||||
lambda: baker.make(
|
||||
User,
|
||||
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||
),
|
||||
lambda: baker.make(
|
||||
User,
|
||||
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
|
||||
),
|
||||
],
|
||||
)
|
||||
|
@ -13,22 +13,41 @@
|
||||
#
|
||||
#
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from typing import Any
|
||||
|
||||
import PIL
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.forms import BaseForm
|
||||
from django.http import HttpRequest
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import SafeString
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import ExifTags
|
||||
from PIL.Image import Image, Resampling
|
||||
|
||||
|
||||
def get_start_of_semester(today: Optional[date] = None) -> date:
|
||||
@dataclass
|
||||
class FormFragmentTemplateData[T: BaseForm]:
|
||||
"""Dataclass used to pre-render form fragments"""
|
||||
|
||||
form: T
|
||||
template: str
|
||||
context: dict[str, Any]
|
||||
|
||||
def render(self, request: HttpRequest) -> SafeString:
|
||||
# Request is needed for csrf_tokens
|
||||
return render_to_string(
|
||||
self.template, context={"form": self.form, **self.context}, request=request
|
||||
)
|
||||
|
||||
|
||||
def get_start_of_semester(today: date | None = None) -> date:
|
||||
"""Return the date of the start of the semester of the given date.
|
||||
If no date is given, return the start date of the current semester.
|
||||
|
||||
@ -58,7 +77,7 @@ def get_start_of_semester(today: Optional[date] = None) -> date:
|
||||
return autumn.replace(year=autumn.year - 1)
|
||||
|
||||
|
||||
def get_semester_code(d: Optional[date] = None) -> str:
|
||||
def get_semester_code(d: date | None = None) -> str:
|
||||
"""Return the semester code of the given date.
|
||||
If no date is given, return the semester code of the current semester.
|
||||
|
||||
|
@ -13,6 +13,7 @@
|
||||
#
|
||||
#
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urljoin
|
||||
|
||||
# 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.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
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.edit import DeleteView, FormMixin, UpdateView
|
||||
|
||||
from core.models import Notification, RealGroup, SithFile, User
|
||||
from core.models import Notification, SithFile, User
|
||||
from core.views import (
|
||||
AllowFragment,
|
||||
CanEditMixin,
|
||||
@ -47,6 +49,41 @@ from core.views.widgets.select import (
|
||||
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(
|
||||
request: HttpRequest,
|
||||
file_id: int,
|
||||
@ -65,28 +102,7 @@ def send_file(
|
||||
raise PermissionDenied
|
||||
name = getattr(f, file_attr).name
|
||||
|
||||
response = HttpResponse(
|
||||
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
|
||||
return send_raw_file(settings.MEDIA_ROOT / name)
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
@ -159,19 +175,18 @@ class AddFilesForm(forms.Form):
|
||||
% {"file_name": f, "msg": repr(e)},
|
||||
)
|
||||
if notif:
|
||||
for u in (
|
||||
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
.first()
|
||||
.users.all()
|
||||
unread_notif_subquery = Notification.objects.filter(
|
||||
user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
|
||||
)
|
||||
for user in User.objects.filter(
|
||||
~Exists(unread_notif_subquery),
|
||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||
):
|
||||
if not u.notifications.filter(
|
||||
type="FILE_MODERATION", viewed=False
|
||||
).exists():
|
||||
Notification(
|
||||
user=u,
|
||||
Notification.objects.create(
|
||||
user=user,
|
||||
url=reverse("core:file_moderation"),
|
||||
type="FILE_MODERATION",
|
||||
).save()
|
||||
)
|
||||
|
||||
|
||||
class FileListView(ListView):
|
||||
|
@ -21,6 +21,7 @@
|
||||
#
|
||||
#
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from io import BytesIO
|
||||
|
||||
from captcha.fields import CaptchaField
|
||||
@ -37,14 +38,16 @@ from django.forms import (
|
||||
DateInput,
|
||||
DateTimeInput,
|
||||
TextInput,
|
||||
Widget,
|
||||
)
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from PIL import Image
|
||||
|
||||
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.views.widgets.select import (
|
||||
AutoCompleteSelect,
|
||||
@ -130,6 +133,23 @@ class SelectUser(TextInput):
|
||||
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
|
||||
|
||||
|
||||
@ -167,14 +187,15 @@ class RegisteringForm(UserCreationForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("first_name", "last_name", "email")
|
||||
field_classes = {
|
||||
"email": AntiSpamEmailField,
|
||||
}
|
||||
field_classes = {"email": AntiSpamEmailField}
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
"""Form handling the user profile, managing the files"""
|
||||
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
@ -287,15 +308,20 @@ class UserProfileForm(forms.ModelForm):
|
||||
self._post_clean()
|
||||
|
||||
|
||||
class UserPropForm(forms.ModelForm):
|
||||
class UserGroupsForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
groups = forms.ModelMultipleChoiceField(
|
||||
queryset=Group.objects.filter(is_manually_manageable=True),
|
||||
widget=CheckboxSelectMultiple,
|
||||
label=_("Groups"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["groups"]
|
||||
help_texts = {"groups": "Which groups this user belongs to"}
|
||||
widgets = {"groups": CheckboxSelectMultiple}
|
||||
|
||||
|
||||
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.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.widgets.select import (
|
||||
AutoCompleteSelectMultipleUser,
|
||||
)
|
||||
from core.views.widgets.select import AutoCompleteSelectMultipleUser
|
||||
|
||||
# Forms
|
||||
|
||||
@ -59,7 +57,8 @@ class EditMembersForm(forms.Form):
|
||||
class GroupListView(CanEditMixin, ListView):
|
||||
"""Displays the Group list."""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
ordering = ["name"]
|
||||
template_name = "core/group_list.jinja"
|
||||
|
||||
@ -67,7 +66,8 @@ class GroupListView(CanEditMixin, ListView):
|
||||
class GroupEditView(CanEditMixin, UpdateView):
|
||||
"""Edit infos of a Group."""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
pk_url_kwarg = "group_id"
|
||||
template_name = "core/group_edit.jinja"
|
||||
fields = ["name", "description"]
|
||||
@ -76,7 +76,8 @@ class GroupEditView(CanEditMixin, UpdateView):
|
||||
class GroupCreateView(CanCreateMixin, CreateView):
|
||||
"""Add a new Group."""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
template_name = "core/create.jinja"
|
||||
fields = ["name", "description"]
|
||||
|
||||
@ -86,7 +87,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
||||
Allow adding and removing users from it.
|
||||
"""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
form_class = EditMembersForm
|
||||
pk_url_kwarg = "group_id"
|
||||
template_name = "core/group_detail.jinja"
|
||||
@ -120,7 +122,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
||||
class GroupDeleteView(CanEditMixin, DeleteView):
|
||||
"""Delete a Group."""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
pk_url_kwarg = "group_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("core:group_list")
|
||||
|
@ -64,16 +64,20 @@ class PageView(CanViewMixin, DetailView):
|
||||
class PageHistView(CanViewMixin, DetailView):
|
||||
model = Page
|
||||
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):
|
||||
res = super().dispatch(request, *args, **kwargs)
|
||||
if self.object.need_club_redirection:
|
||||
return redirect("club:club_hist", club_id=self.object.club.id)
|
||||
return res
|
||||
page = self.get_object()
|
||||
if page.need_club_redirection:
|
||||
return redirect("club:club_hist", club_id=page.club.id)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_object(self):
|
||||
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
|
||||
return self.page
|
||||
def get_object(self, *args, **kwargs):
|
||||
if not self._cached_object:
|
||||
self._cached_object = super().get_object()
|
||||
return self._cached_object
|
||||
|
||||
|
||||
class PageRevView(CanViewMixin, DetailView):
|
||||
|
@ -35,7 +35,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import DateField, QuerySet
|
||||
from django.db.models.functions import Trunc
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
@ -68,10 +67,11 @@ from core.views.forms import (
|
||||
LoginForm,
|
||||
RegisteringForm,
|
||||
UserGodfathersForm,
|
||||
UserGroupsForm,
|
||||
UserProfileForm,
|
||||
)
|
||||
from counter.forms import StudentCardForm
|
||||
from counter.models import Refilling, Selling
|
||||
from counter.views.student_card import StudentCardFormView
|
||||
from eboutic.models import Invoice
|
||||
from subscription.models import Subscription
|
||||
from trombi.views import UserTrombiForm
|
||||
@ -559,10 +559,6 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
context_object_name = "profile"
|
||||
current_tab = "prefs"
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
user = get_object_or_404(User, pk=self.kwargs["user_id"])
|
||||
return user
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
pref = self.object.preferences
|
||||
@ -572,13 +568,12 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
|
||||
if not (
|
||||
hasattr(self.object, "trombi_user") and self.request.user.trombi_user.trombi
|
||||
):
|
||||
if not hasattr(self.object, "trombi_user"):
|
||||
kwargs["trombi_form"] = UserTrombiForm()
|
||||
|
||||
if hasattr(self.object, "customer"):
|
||||
kwargs["student_card_form"] = StudentCardForm()
|
||||
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
|
||||
self.object.customer
|
||||
).render(self.request)
|
||||
return kwargs
|
||||
|
||||
|
||||
@ -588,9 +583,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
|
||||
model = User
|
||||
pk_url_kwarg = "user_id"
|
||||
template_name = "core/user_group.jinja"
|
||||
form_class = modelform_factory(
|
||||
User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple}
|
||||
)
|
||||
form_class = UserGroupsForm
|
||||
context_object_name = "profile"
|
||||
current_tab = "groups"
|
||||
|
||||
|
@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
|
||||
|
||||
@admin.register(ProductType)
|
||||
class ProductTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "priority")
|
||||
list_display = ("name", "order")
|
||||
|
||||
|
||||
@admin.register(CashRegisterSummary)
|
||||
|
115
counter/api.py
115
counter/api.py
@ -12,41 +12,49 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_types import MinLen
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Query
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.permissions import IsAuthenticated
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from core.api_permissions import CanAccessLookup, CanView, IsRoot
|
||||
from counter.models import Counter, Permanency, Product
|
||||
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||
from counter.models import Counter, Product, ProductType
|
||||
from counter.schemas import (
|
||||
CounterFilterSchema,
|
||||
CounterSchema,
|
||||
PermanencyFilterSchema,
|
||||
PermanencySchema,
|
||||
ProductFilterSchema,
|
||||
ProductSchema,
|
||||
ProductTypeSchema,
|
||||
ReorderProductTypeSchema,
|
||||
SimpleProductSchema,
|
||||
SimplifiedCounterSchema,
|
||||
)
|
||||
|
||||
IsCounterAdmin = (
|
||||
IsRoot
|
||||
| IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
| IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/counter")
|
||||
class CounterController(ControllerBase):
|
||||
@route.get("", response=list[CounterSchema], permissions=[IsRoot])
|
||||
def fetch_all(self):
|
||||
return Counter.objects.all()
|
||||
return Counter.objects.annotate_is_open()
|
||||
|
||||
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
|
||||
def fetch_one(self, counter_id: int):
|
||||
return self.get_object_or_exception(Counter.objects.all(), pk=counter_id)
|
||||
return self.get_object_or_exception(
|
||||
Counter.objects.annotate_is_open(), pk=counter_id
|
||||
)
|
||||
|
||||
@route.get("bar/", response=list[CounterSchema], permissions=[CanView])
|
||||
def fetch_bars(self):
|
||||
counters = list(Counter.objects.all().filter(type="BAR"))
|
||||
counters = list(Counter.objects.annotate_is_open().filter(type="BAR"))
|
||||
for c in counters:
|
||||
self.check_object_permissions(c)
|
||||
return counters
|
||||
@ -65,33 +73,72 @@ class CounterController(ControllerBase):
|
||||
class ProductController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[ProductSchema],
|
||||
response=PaginatedResponseSchema[SimpleProductSchema],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_products(self, search: Annotated[str, MinLen(1)]):
|
||||
return (
|
||||
Product.objects.filter(
|
||||
Q(name__icontains=search) | Q(code__icontains=search)
|
||||
)
|
||||
.filter(archived=False)
|
||||
.values()
|
||||
def search_products(self, filters: Query[ProductFilterSchema]):
|
||||
return filters.filter(
|
||||
Product.objects.order_by(
|
||||
F("product_type__order").asc(nulls_last=True),
|
||||
"product_type",
|
||||
"name",
|
||||
).values()
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/permanency")
|
||||
class PermanencyController(ControllerBase):
|
||||
@route.get(
|
||||
"",
|
||||
response=PaginatedResponseSchema[PermanencySchema],
|
||||
permissions=[IsAuthenticated],
|
||||
exclude_none=True,
|
||||
"/search/detailed",
|
||||
response=PaginatedResponseSchema[ProductSchema],
|
||||
permissions=[IsCounterAdmin],
|
||||
url_name="search_products_detailed",
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=100)
|
||||
def fetch_permanencies(self, filters: Query[PermanencyFilterSchema]):
|
||||
return (
|
||||
filters.filter(Permanency.objects.all())
|
||||
.distinct()
|
||||
.order_by("-start")
|
||||
.select_related("counter")
|
||||
@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.utils.translation import gettext_lazy as _
|
||||
|
||||
PAYMENT_METHOD = [
|
||||
("CHECK", _("Check")),
|
||||
("CASH", _("Cash")),
|
||||
("CARD", _("Credit card")),
|
||||
]
|
||||
|
||||
|
||||
class CounterConfig(AppConfig):
|
||||
name = "counter"
|
||||
|
@ -45,16 +45,14 @@ class BillingInfoForm(forms.ModelForm):
|
||||
|
||||
|
||||
class StudentCardForm(forms.ModelForm):
|
||||
"""Form for adding student cards
|
||||
Only used for user profile since CounterClick is to complicated.
|
||||
"""
|
||||
"""Form for adding student cards"""
|
||||
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = StudentCard
|
||||
fields = ["uid"]
|
||||
widgets = {
|
||||
"uid": NFCTextInput,
|
||||
}
|
||||
widgets = {"uid": NFCTextInput}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@ -91,7 +89,7 @@ class GetUserForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
cus = None
|
||||
customer = None
|
||||
if cleaned_data["code"] != "":
|
||||
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
|
||||
card = (
|
||||
@ -100,29 +98,24 @@ class GetUserForm(forms.Form):
|
||||
.first()
|
||||
)
|
||||
if card is not None:
|
||||
cus = card.customer
|
||||
if cus is None:
|
||||
cus = Customer.objects.filter(
|
||||
customer = card.customer
|
||||
if customer is None:
|
||||
customer = Customer.objects.filter(
|
||||
account_id__iexact=cleaned_data["code"]
|
||||
).first()
|
||||
elif cleaned_data["id"] is not None:
|
||||
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||
if cus is None or not cus.can_buy:
|
||||
elif cleaned_data["id"]:
|
||||
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||
|
||||
if customer is None or not customer.can_buy:
|
||||
raise forms.ValidationError(_("User not found"))
|
||||
cleaned_data["user_id"] = cus.user.id
|
||||
cleaned_data["user"] = cus.user
|
||||
cleaned_data["user_id"] = customer.user.id
|
||||
cleaned_data["user"] = customer.user
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class NFCCardForm(forms.Form):
|
||||
student_card_uid = forms.CharField(
|
||||
max_length=StudentCard.UID_SIZE,
|
||||
required=False,
|
||||
widget=NFCTextInput,
|
||||
)
|
||||
|
||||
|
||||
class RefillForm(forms.ModelForm):
|
||||
allowed_refilling_methods = ["CASH", "CARD"]
|
||||
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
amount = forms.FloatField(
|
||||
@ -132,6 +125,21 @@ class RefillForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Refilling
|
||||
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):
|
||||
@ -146,6 +154,9 @@ class CounterEditForm(forms.ModelForm):
|
||||
|
||||
|
||||
class ProductEditForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = [
|
||||
@ -153,7 +164,6 @@ class ProductEditForm(forms.ModelForm):
|
||||
"description",
|
||||
"product_type",
|
||||
"code",
|
||||
"parent_product",
|
||||
"buying_groups",
|
||||
"purchase_price",
|
||||
"selling_price",
|
||||
@ -164,8 +174,13 @@ class ProductEditForm(forms.ModelForm):
|
||||
"tray",
|
||||
"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 = {
|
||||
"parent_product": AutoCompleteSelectMultipleProduct,
|
||||
"product_type": AutoCompleteSelect,
|
||||
"buying_groups": AutoCompleteSelectMultipleGroup,
|
||||
"club": AutoCompleteSelectClub,
|
||||
|
@ -55,7 +55,9 @@ class Command(BaseCommand):
|
||||
customer__user__in=reactivated_users
|
||||
).delete()
|
||||
self._dump_accounts({u.customer for u in users_to_dump})
|
||||
self._send_mails(users_to_dump)
|
||||
self.stdout.write("Accounts dumped")
|
||||
nb_successful_mails = self._send_mails(users_to_dump)
|
||||
self.stdout.write(f"{nb_successful_mails} were successfuly sent.")
|
||||
self.stdout.write("Finished !")
|
||||
|
||||
@staticmethod
|
||||
@ -103,13 +105,14 @@ class Command(BaseCommand):
|
||||
if len(pending_dumps) != len(customer_ids):
|
||||
raise ValueError("One or more accounts were not engaged in a dump process")
|
||||
counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
|
||||
seller = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
|
||||
sales = Selling.objects.bulk_create(
|
||||
[
|
||||
Selling(
|
||||
label="Vidange compte inactif",
|
||||
club=counter.club,
|
||||
counter=counter,
|
||||
seller=None,
|
||||
seller=seller,
|
||||
product=None,
|
||||
customer=account,
|
||||
quantity=1,
|
||||
@ -124,7 +127,7 @@ class Command(BaseCommand):
|
||||
|
||||
# dumps and sales are linked to the same customers
|
||||
# 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
|
||||
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
|
||||
|
||||
@ -134,8 +137,12 @@ class Command(BaseCommand):
|
||||
Customer.objects.filter(pk__in=customer_ids).update(amount=0)
|
||||
|
||||
@staticmethod
|
||||
def _send_mails(users: Iterable[User]):
|
||||
"""Send the mails informing users that their account has been dumped."""
|
||||
def _send_mails(users: Iterable[User]) -> int:
|
||||
"""Send the mails informing users that their account has been dumped.
|
||||
|
||||
Returns:
|
||||
The number of emails successfully sent.
|
||||
"""
|
||||
mails = [
|
||||
(
|
||||
_("Your AE account has been emptied"),
|
||||
@ -145,4 +152,4 @@ class Command(BaseCommand):
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
send_mass_mail(mails)
|
||||
return send_mass_mail(mails, fail_silently=True)
|
||||
|
@ -1,38 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
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):
|
||||
@ -44,5 +12,4 @@ class Migration(migrations.Migration):
|
||||
name="recorded_products",
|
||||
field=models.IntegerField(verbose_name="recorded items", default=0),
|
||||
),
|
||||
migrations.RunPython(balance_ecocups),
|
||||
]
|
||||
|
@ -0,0 +1,38 @@
|
||||
# Generated by Django 4.2.17 on 2024-12-09 11:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import accounting.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0024_accountdump_accountdump_unique_ongoing_dump")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="product", name="parent_product"),
|
||||
migrations.AlterField(
|
||||
model_name="product",
|
||||
name="description",
|
||||
field=models.TextField(default="", verbose_name="description"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="product",
|
||||
name="purchase_price",
|
||||
field=accounting.models.CurrencyField(
|
||||
decimal_places=2,
|
||||
help_text="Initial cost of purchasing the product",
|
||||
max_digits=12,
|
||||
verbose_name="purchase price",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="product",
|
||||
name="special_selling_price",
|
||||
field=accounting.models.CurrencyField(
|
||||
decimal_places=2,
|
||||
help_text="Price for barmen during their permanence",
|
||||
max_digits=12,
|
||||
verbose_name="special selling price",
|
||||
),
|
||||
),
|
||||
]
|
53
counter/migrations/0026_alter_studentcard_customer.py
Normal file
53
counter/migrations/0026_alter_studentcard_customer.py
Normal file
@ -0,0 +1,53 @@
|
||||
# Generated by Django 4.2.17 on 2024-12-08 13:30
|
||||
from operator import attrgetter
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.state import StateApps
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
def delete_duplicates(apps: StateApps, schema_editor):
|
||||
"""Delete cards of users with more than one student cards.
|
||||
|
||||
For all users who have more than one registered student card, all
|
||||
the cards except the last one are deleted.
|
||||
"""
|
||||
Customer = apps.get_model("counter", "Customer")
|
||||
StudentCard = apps.get_model("counter", "StudentCard")
|
||||
customers = (
|
||||
Customer.objects.annotate(nb_cards=Count("student_cards"))
|
||||
.filter(nb_cards__gt=1)
|
||||
.prefetch_related("student_cards")
|
||||
)
|
||||
to_delete = [
|
||||
card.id
|
||||
for customer in customers
|
||||
for card in sorted(customer.student_cards.all(), key=attrgetter("id"))[:-1]
|
||||
]
|
||||
StudentCard.objects.filter(id__in=to_delete).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("counter", "0025_remove_product_parent_product_and_more")]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(delete_duplicates, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="studentcard",
|
||||
name="customer",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="student_card",
|
||||
to="counter.customer",
|
||||
verbose_name="student card",
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="studentcard",
|
||||
options={
|
||||
"verbose_name": "student card",
|
||||
"verbose_name_plural": "student cards",
|
||||
},
|
||||
),
|
||||
]
|
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 timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import Self, Tuple
|
||||
from typing import Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
@ -35,6 +35,7 @@ from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from ordered_model.models import OrderedModel
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from accounting.models import CurrencyField
|
||||
@ -42,7 +43,8 @@ from club.models import Club
|
||||
from core.fields import ResizedImageField
|
||||
from core.models import Group, Notification, User
|
||||
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
|
||||
|
||||
|
||||
@ -136,7 +138,7 @@ class Customer(models.Model):
|
||||
return (date.today() - subscription.subscription_end) < timedelta(days=90)
|
||||
|
||||
@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,
|
||||
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.
|
||||
|
||||
Useful only for categorizing.
|
||||
"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=30)
|
||||
description = models.TextField(_("description"), null=True, blank=True)
|
||||
comment = models.TextField(_("comment"), null=True, blank=True)
|
||||
description = models.TextField(_("description"), default="")
|
||||
comment = models.TextField(
|
||||
_("comment"),
|
||||
default="",
|
||||
help_text=_("A text that will be shown on the eboutic."),
|
||||
)
|
||||
icon = ResizedImageField(
|
||||
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:
|
||||
verbose_name = _("product type")
|
||||
ordering = ["-priority", "name"]
|
||||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("counter:producttype_list")
|
||||
return reverse("counter:product_type_list")
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
@ -325,8 +327,10 @@ class ProductType(models.Model):
|
||||
class Product(models.Model):
|
||||
"""A product, with all its related information."""
|
||||
|
||||
QUANTITY_FOR_TRAY_PRICE = 6
|
||||
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
description = models.TextField(_("description"), default="")
|
||||
product_type = models.ForeignKey(
|
||||
ProductType,
|
||||
related_name="products",
|
||||
@ -336,9 +340,15 @@ class Product(models.Model):
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||
purchase_price = CurrencyField(_("purchase price"))
|
||||
purchase_price = CurrencyField(
|
||||
_("purchase price"),
|
||||
help_text=_("Initial cost of purchasing the product"),
|
||||
)
|
||||
selling_price = CurrencyField(_("selling price"))
|
||||
special_selling_price = CurrencyField(_("special selling price"))
|
||||
special_selling_price = CurrencyField(
|
||||
_("special selling price"),
|
||||
help_text=_("Price for barmen during their permanence"),
|
||||
)
|
||||
icon = ResizedImageField(
|
||||
height=70,
|
||||
force_format="WEBP",
|
||||
@ -352,14 +362,6 @@ class Product(models.Model):
|
||||
)
|
||||
limit_age = models.IntegerField(_("limit age"), default=0)
|
||||
tray = models.BooleanField(_("tray price"), default=False)
|
||||
parent_product = models.ForeignKey(
|
||||
"self",
|
||||
related_name="children_products",
|
||||
verbose_name=_("parent product"),
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
buying_groups = models.ManyToManyField(
|
||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
||||
)
|
||||
@ -369,7 +371,7 @@ class Product(models.Model):
|
||||
verbose_name = _("product")
|
||||
|
||||
def __str__(self):
|
||||
return "%s (%s)" % (self.name, self.code)
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("counter:product_list")
|
||||
@ -525,14 +527,17 @@ class Counter(models.Model):
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
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 user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
|
||||
def can_be_viewed_by(self, user: User) -> bool:
|
||||
if self.type == "BAR":
|
||||
return True
|
||||
return user.is_board_member or user in self.sellers.all()
|
||||
return (
|
||||
self.type == "BAR"
|
||||
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:
|
||||
"""Generate a new token for this counter."""
|
||||
@ -560,9 +565,6 @@ class Counter(models.Model):
|
||||
"""Show if the counter authorize the refilling with physic money."""
|
||||
if self.type != "BAR":
|
||||
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
|
||||
ae = Club.objects.get(unix_name=SITH_MAIN_CLUB["unix_name"])
|
||||
return any(ae.get_membership_for(barman) for barman in self.barmen_list)
|
||||
@ -652,6 +654,42 @@ class Counter(models.Model):
|
||||
)
|
||||
)["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):
|
||||
def annotate_total(self) -> Self:
|
||||
@ -690,8 +728,8 @@ class Refilling(models.Model):
|
||||
payment_method = models.CharField(
|
||||
_("payment method"),
|
||||
max_length=255,
|
||||
choices=settings.SITH_COUNTER_PAYMENT_METHOD,
|
||||
default="CASH",
|
||||
choices=PAYMENT_METHOD,
|
||||
default="CARD",
|
||||
)
|
||||
bank = models.CharField(
|
||||
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
|
||||
@ -756,7 +794,8 @@ class SellingQuerySet(models.QuerySet):
|
||||
class Selling(models.Model):
|
||||
"""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,
|
||||
related_name="sellings",
|
||||
@ -1140,20 +1179,22 @@ class StudentCard(models.Model):
|
||||
uid = models.CharField(
|
||||
_("uid"), max_length=UID_SIZE, unique=True, validators=[MinLengthValidator(4)]
|
||||
)
|
||||
customer = models.ForeignKey(
|
||||
customer = models.OneToOneField(
|
||||
Customer,
|
||||
related_name="student_cards",
|
||||
verbose_name=_("student cards"),
|
||||
null=False,
|
||||
blank=False,
|
||||
related_name="student_card",
|
||||
verbose_name=_("student card"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("student card")
|
||||
verbose_name_plural = _("student cards")
|
||||
|
||||
def __str__(self):
|
||||
return self.uid
|
||||
|
||||
@staticmethod
|
||||
def is_valid(uid):
|
||||
def is_valid(uid: str) -> bool:
|
||||
return (
|
||||
(uid.isupper() or uid.isnumeric())
|
||||
and len(uid) == StudentCard.UID_SIZE
|
||||
|
@ -1,33 +1,22 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Self
|
||||
|
||||
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 counter.models import Counter, Permanency, Product
|
||||
from club.schemas import ClubSchema
|
||||
from core.schemas import GroupSchema, SimpleUserSchema
|
||||
from counter.models import Counter, Product, ProductType
|
||||
|
||||
|
||||
class CounterSchema(ModelSchema):
|
||||
barmen_list: list[SimpleUserSchema]
|
||||
is_open: bool
|
||||
|
||||
class Meta:
|
||||
model = Counter
|
||||
fields = ["id", "name", "type"]
|
||||
|
||||
|
||||
class PermanencySchema(ModelSchema):
|
||||
counter: CounterSchema
|
||||
|
||||
class Meta:
|
||||
model = Permanency
|
||||
fields = ["start", "end"]
|
||||
|
||||
|
||||
class PermanencyFilterSchema(FilterSchema):
|
||||
start_after: datetime | None = Field(None, q="start__gte")
|
||||
start_before: datetime | None = Field(None, q="start__lte")
|
||||
end_after: datetime | None = Field(None, q="end__gte")
|
||||
end_before: datetime | None = Field(None, q="end__lte")
|
||||
took_place_after: datetime | None = Field(None, q=["start__gte", "end__gte"])
|
||||
counter: set[int] | None = Field(None, q="counter_id__in")
|
||||
fields = ["id", "name", "type", "club", "products"]
|
||||
|
||||
|
||||
class CounterFilterSchema(FilterSchema):
|
||||
@ -40,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
|
||||
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:
|
||||
model = Product
|
||||
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 CounterSchema,
|
||||
type ProductSchema,
|
||||
type ProductTypeSchema,
|
||||
type SimpleProductSchema,
|
||||
counterSearchCounter,
|
||||
productSearchProducts,
|
||||
producttypeFetchAll,
|
||||
} from "#openapi";
|
||||
|
||||
@registerComponent("product-ajax-select")
|
||||
@ -23,17 +25,48 @@ export class ProductAjaxSelect extends AjaxSelect {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected renderOption(item: ProductSchema, sanitize: typeof escape_html) {
|
||||
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
|
||||
</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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
export class CounterAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
|
@ -0,0 +1,82 @@
|
||||
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,
|
||||
searchField: ["code", "text"],
|
||||
};
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user