mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-12 21:09:24 +00:00
Compare commits
11 Commits
docker
...
counter-ac
Author | SHA1 | Date | |
---|---|---|---|
dae5cb06e7 | |||
113828f9b6 | |||
203b5d88ac | |||
9206fed4ce | |||
f133bac921 | |||
1bce7e055f | |||
ee19dc01f6 | |||
09dbda87bc | |||
a44e8a68cb | |||
71d155613f | |||
e30a6e8e6e |
83
.env.example
83
.env.example
@ -1,83 +0,0 @@
|
|||||||
HTTPS=off
|
|
||||||
DEBUG=true
|
|
||||||
|
|
||||||
# This is not the real key used in prod
|
|
||||||
SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2
|
|
||||||
|
|
||||||
DATABASE_URL=sqlite:///db.sqlite3
|
|
||||||
# uncomment the next line if you want to use a postgres database
|
|
||||||
#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith
|
|
||||||
CACHE_URL=redis://127.0.0.1:6379/0
|
|
||||||
|
|
||||||
MEDIA_ROOT=data
|
|
||||||
STATIC_ROOT=static
|
|
||||||
|
|
||||||
DEFAULT_FROM_EMAIL=bibou@git.an
|
|
||||||
SITH_COM_EMAIL=bibou_com@git.an
|
|
||||||
|
|
||||||
HONEYPOT_VALUE=content
|
|
||||||
HONEYPOT_FIELD_NAME=body2
|
|
||||||
HONEYPOT_FIELD_NAME_FORUM=message2
|
|
||||||
|
|
||||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
|
||||||
EMAIL_HOST=localhost
|
|
||||||
EMAIL_PORT=25
|
|
||||||
|
|
||||||
SITH_URL=127.0.0.1:8000
|
|
||||||
SITH_NAME="AE UTBM"
|
|
||||||
|
|
||||||
SITH_MAIN_CLUB_ID=1
|
|
||||||
|
|
||||||
SITH_GROUP_ROOT_ID=1
|
|
||||||
SITH_GROUP_PUBLIC_ID=2
|
|
||||||
SITH_GROUP_SUBSCRIBERS_ID=3
|
|
||||||
SITH_GROUP_OLD_SUBSCRIBERS_ID=4
|
|
||||||
SITH_GROUP_ACCOUNTING_ADMIN_ID=5
|
|
||||||
SITH_GROUP_COM_ADMIN_ID=6
|
|
||||||
SITH_GROUP_COUNTER_ADMIN_ID=7
|
|
||||||
SITH_GROUP_SAS_ADMIN_ID=8
|
|
||||||
SITH_GROUP_FORUM_ADMIN_ID=9
|
|
||||||
SITH_GROUP_PEDAGOGY_ADMIN_ID=10
|
|
||||||
|
|
||||||
SITH_GROUP_BANNED_ALCOHOL_ID=11
|
|
||||||
SITH_GROUP_BANNED_COUNTER_ID=12
|
|
||||||
SITH_GROUP_BANNED_SUBSCRIPTION_ID=13
|
|
||||||
|
|
||||||
SITH_CLUB_REFOUND_ID=89
|
|
||||||
SITH_COUNTER_REFOUND_ID=38
|
|
||||||
SITH_PRODUCT_REFOUND_ID=5
|
|
||||||
|
|
||||||
# Counter
|
|
||||||
|
|
||||||
SITH_COUNTER_ACCOUNT_DUMP_ID=39
|
|
||||||
|
|
||||||
# Defines which product type is the refilling type, and thus increases the account amount
|
|
||||||
SITH_COUNTER_PRODUCTTYPE_REFILLING=3
|
|
||||||
|
|
||||||
SITH_ECOCUP_CONS=1152
|
|
||||||
SITH_ECOCUP_DECO=1151
|
|
||||||
|
|
||||||
# Defines which product is the one year subscription and which one is the six month subscription
|
|
||||||
SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER=1
|
|
||||||
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS=2
|
|
||||||
SITH_PRODUCTTYPE_SUBSCRIPTION=2
|
|
||||||
|
|
||||||
# Defines which clubs let its members the ability to see users subscription history
|
|
||||||
SITH_CAN_CREATE_SUBSCRIPTION_HISTORY=1
|
|
||||||
SITH_CAN_READ_SUBSCRIPTION_HISTORY=1
|
|
||||||
|
|
||||||
# SAS variables
|
|
||||||
SITH_SAS_ROOT_DIR_ID=4
|
|
||||||
|
|
||||||
# ET variables
|
|
||||||
SITH_EBOUTIC_CB_ENABLED=true
|
|
||||||
SITH_EBOUTIC_ET_URL="https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
|
|
||||||
SITH_EBOUTIC_PBX_SITE=1999888
|
|
||||||
SITH_EBOUTIC_PBX_RANG=32
|
|
||||||
SITH_EBOUTIC_PBX_IDENTIFIANT=2
|
|
||||||
SITH_EBOUTIC_HMAC_KEY=0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF
|
|
||||||
SITH_EBOUTIC_PUB_KEY_PATH=sith/et_keys/pubkey.pem
|
|
||||||
|
|
||||||
SITH_MAILING_FETCH_KEY=IloveMails
|
|
||||||
SENTRY_DSN=
|
|
||||||
SENTRY_ENV=production
|
|
14
.envrc
14
.envrc
@ -1,6 +1,14 @@
|
|||||||
if [[ ! -d .venv ]]; then
|
if [[ ! -f pyproject.toml ]]; then
|
||||||
log_error 'No .venv folder found. Use `uv sync` to create one first.'
|
log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
. .venv/bin/activate
|
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"
|
8
.github/actions/compile_messages/action.yml
vendored
Normal file
8
.github/actions/compile_messages/action.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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,38 +9,43 @@ runs:
|
|||||||
packages: gettext
|
packages: gettext
|
||||||
version: 1.0 # increment to reset cache
|
version: 1.0 # increment to reset cache
|
||||||
|
|
||||||
- name: Install uv
|
- name: Set up python
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
version: "0.5.14"
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: "uv.lock"
|
|
||||||
|
|
||||||
- name: "Set up Python"
|
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Restore cached virtualenv
|
- name: Load cached Poetry installation
|
||||||
uses: actions/cache/restore@v4
|
id: cached-poetry
|
||||||
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
path: ~/.local
|
||||||
path: .venv
|
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-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync
|
run: poetry install --with docs,tests
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Xapian
|
- name: Install xapian
|
||||||
run: uv run ./manage.py install_xapian
|
run: poetry run ./manage.py install_xapian
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Save cached virtualenv
|
|
||||||
uses: actions/cache/save@v4
|
|
||||||
with:
|
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
|
||||||
path: .venv
|
|
||||||
|
|
||||||
- name: Compile gettext messages
|
- name: Compile gettext messages
|
||||||
run: uv run ./manage.py compilemessages
|
run: poetry run ./manage.py compilemessages
|
||||||
shell: bash
|
shell: bash
|
||||||
|
10
.github/actions/setup_xapian/action.yml
vendored
Normal file
10
.github/actions/setup_xapian/action.yml
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
name: "Setup xapian"
|
||||||
|
description: "Setup the xapian indexes"
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Setup xapian index
|
||||||
|
run: |
|
||||||
|
mkdir -p /dev/shm/search_indexes
|
||||||
|
ln -s /dev/shm/search_indexes sith/search_indexes
|
||||||
|
shell: bash
|
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@ -7,10 +7,6 @@ on:
|
|||||||
branches: [master, taiste]
|
branches: [master, taiste]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
|
||||||
SECRET_KEY: notTheRealOne
|
|
||||||
DATABASE_URL: sqlite:///db.sqlite3
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
name: Launch pre-commits checks (ruff)
|
name: Launch pre-commits checks (ruff)
|
||||||
@ -18,8 +14,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
|
||||||
python-version-file: ".python-version"
|
|
||||||
- uses: pre-commit/action@v3.0.1
|
- uses: pre-commit/action@v3.0.1
|
||||||
with:
|
with:
|
||||||
extra_args: --all-files
|
extra_args: --all-files
|
||||||
@ -35,15 +29,14 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
env:
|
- uses: ./.github/actions/setup_xapian
|
||||||
# To avoid race conditions on environment cache
|
- uses: ./.github/actions/compile_messages
|
||||||
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
|
run: poetry run coverage run -m pytest -m "${{ matrix.pytest-mark }}"
|
||||||
- name: Generate coverage report
|
- name: Generate coverage report
|
||||||
run: |
|
run: |
|
||||||
uv run coverage report
|
poetry run coverage report
|
||||||
uv run coverage html
|
poetry run coverage html
|
||||||
- name: Archive code coverage results
|
- name: Archive code coverage results
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
28
.github/workflows/deploy.yml
vendored
28
.github/workflows/deploy.yml
vendored
@ -37,29 +37,11 @@ jobs:
|
|||||||
|
|
||||||
git fetch
|
git fetch
|
||||||
git reset --hard origin/master
|
git reset --hard origin/master
|
||||||
uv sync --group prod
|
poetry install --with prod --without docs,tests
|
||||||
npm install
|
npm install
|
||||||
uv run ./manage.py install_xapian
|
poetry run ./manage.py install_xapian
|
||||||
uv run ./manage.py migrate
|
poetry run ./manage.py migrate
|
||||||
uv run ./manage.py collectstatic --clear --noinput
|
poetry run ./manage.py collectstatic --clear --noinput
|
||||||
uv run ./manage.py compilemessages
|
poetry run ./manage.py compilemessages
|
||||||
|
|
||||||
sudo systemctl restart uwsgi
|
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
|
path: .cache
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
mkdocs-material-
|
mkdocs-material-
|
||||||
- run: uv run mkdocs gh-deploy --force
|
- run: poetry run mkdocs gh-deploy --force
|
10
.github/workflows/taiste.yml
vendored
10
.github/workflows/taiste.yml
vendored
@ -36,11 +36,11 @@ jobs:
|
|||||||
|
|
||||||
git fetch
|
git fetch
|
||||||
git reset --hard origin/taiste
|
git reset --hard origin/taiste
|
||||||
uv sync --group prod
|
poetry install --with prod --without docs,tests
|
||||||
npm install
|
npm install
|
||||||
uv run ./manage.py install_xapian
|
poetry run ./manage.py install_xapian
|
||||||
uv run ./manage.py migrate
|
poetry run ./manage.py migrate
|
||||||
uv run ./manage.py collectstatic --clear --noinput
|
poetry run ./manage.py collectstatic --clear --noinput
|
||||||
uv run ./manage.py compilemessages
|
poetry run ./manage.py compilemessages
|
||||||
|
|
||||||
sudo systemctl restart uwsgi
|
sudo systemctl restart uwsgi
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,7 +8,7 @@ pyrightconfig.json
|
|||||||
dist/
|
dist/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.venv/
|
env/
|
||||||
doc/html
|
doc/html
|
||||||
data/
|
data/
|
||||||
galaxy/test_galaxy_state.json
|
galaxy/test_galaxy_state.json
|
||||||
@ -21,4 +21,3 @@ node_modules/
|
|||||||
|
|
||||||
# compiled documentation
|
# compiled documentation
|
||||||
site/
|
site/
|
||||||
.env
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.8.3
|
rev: v0.6.9
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff # just check the code, and print the errors
|
- id: ruff # just check the code, and print the errors
|
||||||
- id: ruff # actually fix the fixable errors, but print nothing
|
- id: ruff # actually fix the fixable errors, but print nothing
|
||||||
@ -14,7 +14,7 @@ repos:
|
|||||||
- id: biome-check
|
- id: biome-check
|
||||||
additional_dependencies: ["@biomejs/biome@1.9.3"]
|
additional_dependencies: ["@biomejs/biome@1.9.3"]
|
||||||
- repo: https://github.com/rtts/djhtml
|
- repo: https://github.com/rtts/djhtml
|
||||||
rev: 3.0.7
|
rev: 3.0.6
|
||||||
hooks:
|
hooks:
|
||||||
- id: djhtml
|
- id: djhtml
|
||||||
name: format templates
|
name: format templates
|
||||||
|
@ -1 +0,0 @@
|
|||||||
3.12
|
|
@ -216,7 +216,7 @@ class TestOperation(TestCase):
|
|||||||
self.journal.operations.filter(target_label="Le fantome du jour").exists()
|
self.journal.operations.filter(target_label="Le fantome du jour").exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_operation_simple_accounting(self):
|
def test__operation_simple_accounting(self):
|
||||||
sat = SimplifiedAccountingType.objects.all().first()
|
sat = SimplifiedAccountingType.objects.all().first()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("accounting:op_new", args=[self.journal.id]),
|
reverse("accounting:op_new", args=[self.journal.id]),
|
||||||
@ -237,14 +237,15 @@ class TestOperation(TestCase):
|
|||||||
"done": False,
|
"done": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code != 403
|
self.assertFalse(response.status_code == 403)
|
||||||
assert self.journal.operations.filter(amount=23).exists()
|
self.assertTrue(self.journal.operations.filter(amount=23).exists())
|
||||||
response_get = self.client.get(
|
response_get = self.client.get(
|
||||||
reverse("accounting:journal_details", args=[self.journal.id])
|
reverse("accounting:journal_details", args=[self.journal.id])
|
||||||
)
|
)
|
||||||
assert "<td>Le fantome de l'aurore</td>" in str(response_get.content)
|
self.assertTrue(
|
||||||
|
"<td>Le fantome de l'aurore</td>" in str(response_get.content)
|
||||||
assert (
|
)
|
||||||
|
self.assertTrue(
|
||||||
self.journal.operations.filter(amount=23)
|
self.journal.operations.filter(amount=23)
|
||||||
.values("accounting_type")
|
.values("accounting_type")
|
||||||
.first()["accounting_type"]
|
.first()["accounting_type"]
|
||||||
|
@ -215,14 +215,17 @@ class JournalTabsMixin(TabedViewMixin):
|
|||||||
return _("Journal")
|
return _("Journal")
|
||||||
|
|
||||||
def get_list_of_tabs(self):
|
def get_list_of_tabs(self):
|
||||||
return [
|
tab_list = []
|
||||||
|
tab_list.append(
|
||||||
{
|
{
|
||||||
"url": reverse(
|
"url": reverse(
|
||||||
"accounting:journal_details", kwargs={"j_id": self.object.id}
|
"accounting:journal_details", kwargs={"j_id": self.object.id}
|
||||||
),
|
),
|
||||||
"slug": "journal",
|
"slug": "journal",
|
||||||
"name": _("Journal"),
|
"name": _("Journal"),
|
||||||
},
|
}
|
||||||
|
)
|
||||||
|
tab_list.append(
|
||||||
{
|
{
|
||||||
"url": reverse(
|
"url": reverse(
|
||||||
"accounting:journal_nature_statement",
|
"accounting:journal_nature_statement",
|
||||||
@ -230,7 +233,9 @@ class JournalTabsMixin(TabedViewMixin):
|
|||||||
),
|
),
|
||||||
"slug": "nature_statement",
|
"slug": "nature_statement",
|
||||||
"name": _("Statement by nature"),
|
"name": _("Statement by nature"),
|
||||||
},
|
}
|
||||||
|
)
|
||||||
|
tab_list.append(
|
||||||
{
|
{
|
||||||
"url": reverse(
|
"url": reverse(
|
||||||
"accounting:journal_person_statement",
|
"accounting:journal_person_statement",
|
||||||
@ -238,7 +243,9 @@ class JournalTabsMixin(TabedViewMixin):
|
|||||||
),
|
),
|
||||||
"slug": "person_statement",
|
"slug": "person_statement",
|
||||||
"name": _("Statement by person"),
|
"name": _("Statement by person"),
|
||||||
},
|
}
|
||||||
|
)
|
||||||
|
tab_list.append(
|
||||||
{
|
{
|
||||||
"url": reverse(
|
"url": reverse(
|
||||||
"accounting:journal_accounting_statement",
|
"accounting:journal_accounting_statement",
|
||||||
@ -246,8 +253,9 @@ class JournalTabsMixin(TabedViewMixin):
|
|||||||
),
|
),
|
||||||
"slug": "accounting_statement",
|
"slug": "accounting_statement",
|
||||||
"name": _("Accounting statement"),
|
"name": _("Accounting statement"),
|
||||||
},
|
}
|
||||||
]
|
)
|
||||||
|
return tab_list
|
||||||
|
|
||||||
|
|
||||||
class JournalCreateView(CanCreateMixin, CreateView):
|
class JournalCreateView(CanCreateMixin, CreateView):
|
||||||
|
@ -20,14 +20,6 @@ from club.models import Club, Membership
|
|||||||
@admin.register(Club)
|
@admin.register(Club)
|
||||||
class ClubAdmin(admin.ModelAdmin):
|
class ClubAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "unix_name", "parent", "is_active")
|
list_display = ("name", "unix_name", "parent", "is_active")
|
||||||
search_fields = ("name", "unix_name")
|
|
||||||
autocomplete_fields = (
|
|
||||||
"parent",
|
|
||||||
"board_group",
|
|
||||||
"members_group",
|
|
||||||
"home",
|
|
||||||
"page",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Membership)
|
@admin.register(Membership)
|
||||||
|
@ -3,6 +3,19 @@ from __future__ import unicode_literals
|
|||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from club.models import Club
|
||||||
|
from core.operations import PsqlRunOnly
|
||||||
|
|
||||||
|
|
||||||
|
def generate_club_pages(apps, schema_editor):
|
||||||
|
def recursive_generate_club_page(club):
|
||||||
|
club.make_page()
|
||||||
|
for child in Club.objects.filter(parent=club).all():
|
||||||
|
recursive_generate_club_page(child)
|
||||||
|
|
||||||
|
for club in Club.objects.filter(parent=None).all():
|
||||||
|
recursive_generate_club_page(club)
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
|
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
|
||||||
@ -35,4 +48,11 @@ class Migration(migrations.Migration):
|
|||||||
null=True,
|
null=True,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PsqlRunOnly(
|
||||||
|
"SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop
|
||||||
|
),
|
||||||
|
migrations.RunPython(generate_club_pages),
|
||||||
|
PsqlRunOnly(
|
||||||
|
migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,106 +0,0 @@
|
|||||||
# 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
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,36 +0,0 @@
|
|||||||
# Generated by Django 4.2.17 on 2025-01-04 16:46
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("club", "0012_club_board_group_club_members_group")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="club",
|
|
||||||
name="board_group",
|
|
||||||
field=models.OneToOneField(
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name="club_board",
|
|
||||||
to="core.group",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="club",
|
|
||||||
name="members_group",
|
|
||||||
field=models.OneToOneField(
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name="club",
|
|
||||||
to="core.group",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="membership",
|
|
||||||
constraint=models.CheckConstraint(
|
|
||||||
check=models.Q(("end_date__gte", models.F("start_date"))),
|
|
||||||
name="end_after_start",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
328
club/models.py
328
club/models.py
@ -23,7 +23,7 @@
|
|||||||
#
|
#
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Iterable, Self
|
from typing import Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import validators
|
from django.core import validators
|
||||||
@ -31,14 +31,14 @@ from django.core.cache import cache
|
|||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.core.validators import RegexValidator, validate_email
|
from django.core.validators import RegexValidator, validate_email
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Exists, F, OuterRef, Q
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.models import Group, Notification, Page, SithFile, User
|
from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
|
||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
|
||||||
@ -79,6 +79,19 @@ class Club(models.Model):
|
|||||||
_("short description"), max_length=1000, default="", blank=True, null=True
|
_("short description"), max_length=1000, default="", blank=True, null=True
|
||||||
)
|
)
|
||||||
address = models.CharField(_("address"), max_length=254)
|
address = models.CharField(_("address"), max_length=254)
|
||||||
|
|
||||||
|
owner_group = models.ForeignKey(
|
||||||
|
Group,
|
||||||
|
related_name="owned_club",
|
||||||
|
default=get_default_owner_group,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
edit_groups = models.ManyToManyField(
|
||||||
|
Group, related_name="editable_club", blank=True
|
||||||
|
)
|
||||||
|
view_groups = models.ManyToManyField(
|
||||||
|
Group, related_name="viewable_club", blank=True
|
||||||
|
)
|
||||||
home = models.OneToOneField(
|
home = models.OneToOneField(
|
||||||
SithFile,
|
SithFile,
|
||||||
related_name="home_of_club",
|
related_name="home_of_club",
|
||||||
@ -90,12 +103,6 @@ class Club(models.Model):
|
|||||||
page = models.OneToOneField(
|
page = models.OneToOneField(
|
||||||
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
|
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
members_group = models.OneToOneField(
|
|
||||||
Group, related_name="club", on_delete=models.PROTECT
|
|
||||||
)
|
|
||||||
board_group = models.OneToOneField(
|
|
||||||
Group, related_name="club_board", on_delete=models.PROTECT
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name", "unix_name"]
|
ordering = ["name", "unix_name"]
|
||||||
@ -105,27 +112,23 @@ class Club(models.Model):
|
|||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
creation = self._state.adding
|
old = Club.objects.filter(id=self.id).first()
|
||||||
if not creation:
|
creation = old is None
|
||||||
db_club = Club.objects.get(id=self.id)
|
if not creation and old.unix_name != self.unix_name:
|
||||||
if self.unix_name != db_club.unix_name:
|
self._change_unixname(self.unix_name)
|
||||||
self.home.name = self.unix_name
|
|
||||||
self.home.save()
|
|
||||||
if self.name != db_club.name:
|
|
||||||
self.board_group.name = f"{self.name} - Bureau"
|
|
||||||
self.board_group.save()
|
|
||||||
self.members_group.name = f"{self.name} - Membres"
|
|
||||||
self.members_group.save()
|
|
||||||
if creation:
|
|
||||||
self.board_group = Group.objects.create(
|
|
||||||
name=f"{self.name} - Bureau", is_manually_manageable=False
|
|
||||||
)
|
|
||||||
self.members_group = Group.objects.create(
|
|
||||||
name=f"{self.name} - Membres", is_manually_manageable=False
|
|
||||||
)
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if creation:
|
if creation:
|
||||||
|
board = MetaGroup(name=self.unix_name + settings.SITH_BOARD_SUFFIX)
|
||||||
|
board.save()
|
||||||
|
member = MetaGroup(name=self.unix_name + settings.SITH_MEMBER_SUFFIX)
|
||||||
|
member.save()
|
||||||
|
subscribers = Group.objects.filter(
|
||||||
|
name=settings.SITH_MAIN_MEMBERS_GROUP
|
||||||
|
).first()
|
||||||
self.make_home()
|
self.make_home()
|
||||||
|
self.home.edit_groups.set([board])
|
||||||
|
self.home.view_groups.set([member, subscribers])
|
||||||
|
self.home.save()
|
||||||
self.make_page()
|
self.make_page()
|
||||||
cache.set(f"sith_club_{self.unix_name}", self)
|
cache.set(f"sith_club_{self.unix_name}", self)
|
||||||
|
|
||||||
@ -133,8 +136,7 @@ class Club(models.Model):
|
|||||||
return reverse("club:club_view", kwargs={"club_id": self.id})
|
return reverse("club:club_view", kwargs={"club_id": self.id})
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def president(self) -> Membership | None:
|
def president(self):
|
||||||
"""Fetch the membership of the current president of this club."""
|
|
||||||
return self.members.filter(
|
return self.members.filter(
|
||||||
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
||||||
).first()
|
).first()
|
||||||
@ -152,18 +154,36 @@ class Club(models.Model):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
self.check_loop()
|
self.check_loop()
|
||||||
|
|
||||||
def make_home(self) -> None:
|
def _change_unixname(self, old_name, new_name):
|
||||||
if self.home:
|
c = Club.objects.filter(unix_name=new_name).first()
|
||||||
return
|
if c is None:
|
||||||
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
|
# Update all the groups names
|
||||||
root = User.objects.filter(username="root").first()
|
Group.objects.filter(name=old_name).update(name=new_name)
|
||||||
if home_root and root:
|
Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
|
||||||
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
|
name=new_name + settings.SITH_BOARD_SUFFIX
|
||||||
home.save()
|
)
|
||||||
self.home = home
|
Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
|
||||||
self.save()
|
name=new_name + settings.SITH_MEMBER_SUFFIX
|
||||||
|
)
|
||||||
|
|
||||||
def make_page(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:
|
||||||
|
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
|
||||||
|
root = User.objects.filter(username="root").first()
|
||||||
|
if home_root and root:
|
||||||
|
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
|
||||||
|
home.save()
|
||||||
|
self.home = home
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def make_page(self):
|
||||||
root = User.objects.filter(username="root").first()
|
root = User.objects.filter(username="root").first()
|
||||||
if not self.page:
|
if not self.page:
|
||||||
club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
|
club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
|
||||||
@ -193,34 +213,35 @@ class Club(models.Model):
|
|||||||
self.page.parent = self.parent.page
|
self.page.parent = self.parent.page
|
||||||
self.page.save(force_lock=True)
|
self.page.save(force_lock=True)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
def delete(self, *args, **kwargs):
|
||||||
# Invalidate the cache of this club and of its memberships
|
# Invalidate the cache of this club and of its memberships
|
||||||
for membership in self.members.ongoing().select_related("user"):
|
for membership in self.members.ongoing().select_related("user"):
|
||||||
cache.delete(f"membership_{self.id}_{membership.user.id}")
|
cache.delete(f"membership_{self.id}_{membership.user.id}")
|
||||||
cache.delete(f"sith_club_{self.unix_name}")
|
cache.delete(f"sith_club_{self.unix_name}")
|
||||||
self.board_group.delete()
|
super().delete(*args, **kwargs)
|
||||||
self.members_group.delete()
|
|
||||||
return super().delete(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_display_name(self) -> str:
|
def get_display_name(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def is_owned_by(self, user: User) -> bool:
|
def is_owned_by(self, user):
|
||||||
"""Method to see if that object can be super edited by the given user."""
|
"""Method to see if that object can be super edited by the given user."""
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
return user.is_root or user.is_board_member
|
return user.is_board_member
|
||||||
|
|
||||||
def get_full_logo_url(self) -> str:
|
def get_full_logo_url(self):
|
||||||
return f"https://{settings.SITH_URL}{self.logo.url}"
|
return "https://%s%s" % (settings.SITH_URL, self.logo.url)
|
||||||
|
|
||||||
def can_be_edited_by(self, user: User) -> bool:
|
def can_be_edited_by(self, user):
|
||||||
"""Method to see if that object can be edited by the given user."""
|
"""Method to see if that object can be edited by the given user."""
|
||||||
return self.has_rights_in_club(user)
|
return self.has_rights_in_club(user)
|
||||||
|
|
||||||
def can_be_viewed_by(self, user: User) -> bool:
|
def can_be_viewed_by(self, user):
|
||||||
"""Method to see if that object can be seen by the given user."""
|
"""Method to see if that object can be seen by the given user."""
|
||||||
return user.was_subscribed
|
sub = User.objects.filter(pk=user.pk).first()
|
||||||
|
if sub is None:
|
||||||
|
return False
|
||||||
|
return sub.was_subscribed
|
||||||
|
|
||||||
def get_membership_for(self, user: User) -> Membership | None:
|
def get_membership_for(self, user: User) -> Membership | None:
|
||||||
"""Return the current membership the given user.
|
"""Return the current membership the given user.
|
||||||
@ -241,8 +262,9 @@ class Club(models.Model):
|
|||||||
cache.set(f"membership_{self.id}_{user.id}", membership)
|
cache.set(f"membership_{self.id}_{user.id}", membership)
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
def has_rights_in_club(self, user: User) -> bool:
|
def has_rights_in_club(self, user):
|
||||||
return user.is_in_group(pk=self.board_group_id)
|
m = self.get_membership_for(user)
|
||||||
|
return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
|
|
||||||
|
|
||||||
class MembershipQuerySet(models.QuerySet):
|
class MembershipQuerySet(models.QuerySet):
|
||||||
@ -261,65 +283,42 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
"""
|
"""
|
||||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
|
||||||
def update(self, **kwargs) -> int:
|
def update(self, **kwargs):
|
||||||
"""Refresh the cache and edit group ownership.
|
"""Refresh the cache for the elements of the queryset.
|
||||||
|
|
||||||
Update the cache, when necessary, remove
|
Besides that, does the same job as a regular update method.
|
||||||
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 three db queries :
|
Be aware that this adds a db query to retrieve the updated objects
|
||||||
one to retrieve the updated memberships,
|
|
||||||
one to perform group removal and one to perform
|
|
||||||
group attribution.
|
|
||||||
"""
|
"""
|
||||||
nb_rows = super().update(**kwargs)
|
nb_rows = super().update(**kwargs)
|
||||||
if nb_rows == 0:
|
if nb_rows > 0:
|
||||||
# if no row was affected, no need to refresh the cache
|
# if at least a row was affected, refresh the cache
|
||||||
return 0
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
cache_memberships = {}
|
def delete(self):
|
||||||
memberships = set(self.select_related("club"))
|
|
||||||
# delete all User-Group relations and recreate the necessary ones
|
|
||||||
# It's more concise to write and more reliable
|
|
||||||
Membership._remove_club_groups(memberships)
|
|
||||||
Membership._add_club_groups(memberships)
|
|
||||||
for member in memberships:
|
|
||||||
cache_key = f"membership_{member.club_id}_{member.user_id}"
|
|
||||||
if member.end_date is None:
|
|
||||||
cache_memberships[cache_key] = member
|
|
||||||
else:
|
|
||||||
cache_memberships[cache_key] = "not_member"
|
|
||||||
cache.set_many(cache_memberships)
|
|
||||||
return nb_rows
|
|
||||||
|
|
||||||
def delete(self) -> tuple[int, dict[str, int]]:
|
|
||||||
"""Work just like the default Django's delete() method,
|
"""Work just like the default Django's delete() method,
|
||||||
but add a cache invalidation for the elements of the queryset
|
but add a cache invalidation for the elements of the queryset
|
||||||
before the deletion,
|
before the deletion.
|
||||||
and a removal of the user from the club groups.
|
|
||||||
|
|
||||||
Be aware that this adds some db queries :
|
Be aware that this adds a db query to retrieve the deleted element.
|
||||||
|
As this first query take place before the deletion operation,
|
||||||
- 1 to retrieve the deleted elements in order to perform
|
it will be performed even if the deletion fails.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
memberships = set(self.all())
|
ids = list(self.values_list("club_id", "user_id"))
|
||||||
nb_rows, rows_counts = super().delete()
|
nb_rows, _ = super().delete()
|
||||||
if nb_rows > 0:
|
if nb_rows > 0:
|
||||||
Membership._remove_club_groups(memberships)
|
for club_id, user_id in ids:
|
||||||
cache.set_many(
|
cache.set(f"membership_{club_id}_{user_id}", "not_member")
|
||||||
{
|
|
||||||
f"membership_{m.club_id}_{m.user_id}": "not_member"
|
|
||||||
for m in memberships
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return nb_rows, rows_counts
|
|
||||||
|
|
||||||
|
|
||||||
class Membership(models.Model):
|
class Membership(models.Model):
|
||||||
@ -362,13 +361,6 @@ class Membership(models.Model):
|
|||||||
|
|
||||||
objects = MembershipQuerySet.as_manager()
|
objects = MembershipQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
|
||||||
constraints = [
|
|
||||||
models.CheckConstraint(
|
|
||||||
check=Q(end_date__gte=F("start_date")), name="end_after_start"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
return (
|
||||||
f"{self.club.name} - {self.user.username} "
|
f"{self.club.name} - {self.user.username} "
|
||||||
@ -378,14 +370,7 @@ class Membership(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
# a save may either be an update or a creation
|
|
||||||
# and may result in either an ongoing or an ended membership.
|
|
||||||
# It could also be a retrogradation from the board to being a simple member.
|
|
||||||
# To avoid problems, the user is removed from the club groups beforehand ;
|
|
||||||
# he will be added back if necessary
|
|
||||||
self._remove_club_groups([self])
|
|
||||||
if self.end_date is None:
|
if self.end_date is None:
|
||||||
self._add_club_groups([self])
|
|
||||||
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
||||||
else:
|
else:
|
||||||
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
|
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
|
||||||
@ -393,11 +378,11 @@ class Membership(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
return reverse("club:club_members", kwargs={"club_id": self.club_id})
|
||||||
|
|
||||||
def is_owned_by(self, user: User) -> bool:
|
def is_owned_by(self, user):
|
||||||
"""Method to see if that object can be super edited by the given user."""
|
"""Method to see if that object can be super edited by the given user."""
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
return user.is_root or user.is_board_member
|
return user.is_board_member
|
||||||
|
|
||||||
def can_be_edited_by(self, user: User) -> bool:
|
def can_be_edited_by(self, user: User) -> bool:
|
||||||
"""Check if that object can be edited by the given user."""
|
"""Check if that object can be edited by the given user."""
|
||||||
@ -407,91 +392,9 @@ class Membership(models.Model):
|
|||||||
return membership is not None and membership.role >= self.role
|
return membership is not None and membership.role >= self.role
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self._remove_club_groups([self])
|
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
cache.delete(f"membership_{self.club_id}_{self.user_id}")
|
cache.delete(f"membership_{self.club_id}_{self.user_id}")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _remove_club_groups(
|
|
||||||
memberships: Iterable[Membership],
|
|
||||||
) -> tuple[int, dict[str, int]]:
|
|
||||||
"""Remove users of those memberships from the club groups.
|
|
||||||
|
|
||||||
For example, if a user is in the Troll club board,
|
|
||||||
he is in the board group and the members group of the Troll.
|
|
||||||
After calling this function, he will be in neither.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The result of the deletion queryset.
|
|
||||||
|
|
||||||
Warnings:
|
|
||||||
If this function isn't used in combination
|
|
||||||
with an actual deletion of the memberships,
|
|
||||||
it will result in an inconsistent state,
|
|
||||||
where users will be in the clubs, without
|
|
||||||
having the associated rights.
|
|
||||||
"""
|
|
||||||
clubs = {m.club_id for m in memberships}
|
|
||||||
users = {m.user_id for m in memberships}
|
|
||||||
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
|
|
||||||
return User.groups.through.objects.filter(
|
|
||||||
Q(group__in=groups) & Q(user__in=users)
|
|
||||||
).delete()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _add_club_groups(
|
|
||||||
memberships: Iterable[Membership],
|
|
||||||
) -> list[User.groups.through]:
|
|
||||||
"""Add users of those memberships to the club groups.
|
|
||||||
|
|
||||||
For example, if a user just joined the Troll club board,
|
|
||||||
he will be added in both the members group and the board group
|
|
||||||
of the club.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The created User-Group relations.
|
|
||||||
|
|
||||||
Warnings:
|
|
||||||
If this function isn't used in combination
|
|
||||||
with an actual update/creation of the memberships,
|
|
||||||
it will result in an inconsistent state,
|
|
||||||
where users will have the rights associated to the
|
|
||||||
club, without actually being part of it.
|
|
||||||
"""
|
|
||||||
# only active membership (i.e. `end_date=None`)
|
|
||||||
# grant the attribution of club groups.
|
|
||||||
memberships = [m for m in memberships if m.end_date is None]
|
|
||||||
if not memberships:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
|
|
||||||
# if more than one membership hasn't its `club` attribute set
|
|
||||||
# it's less expensive to reload the whole query with
|
|
||||||
# a select_related than perform a distinct query
|
|
||||||
# to fetch each club.
|
|
||||||
ids = {m.id for m in memberships}
|
|
||||||
memberships = list(
|
|
||||||
Membership.objects.filter(id__in=ids).select_related("club")
|
|
||||||
)
|
|
||||||
club_groups = []
|
|
||||||
for membership in memberships:
|
|
||||||
club_groups.append(
|
|
||||||
User.groups.through(
|
|
||||||
user_id=membership.user_id,
|
|
||||||
group_id=membership.club.members_group_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
|
||||||
club_groups.append(
|
|
||||||
User.groups.through(
|
|
||||||
user_id=membership.user_id,
|
|
||||||
group_id=membership.club.board_group_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return User.groups.through.objects.bulk_create(
|
|
||||||
club_groups, ignore_conflicts=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Mailing(models.Model):
|
class Mailing(models.Model):
|
||||||
"""A Mailing list for a club.
|
"""A Mailing list for a club.
|
||||||
@ -535,18 +438,19 @@ class Mailing(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.is_moderated:
|
if not self.is_moderated:
|
||||||
unread_notif_subquery = Notification.objects.filter(
|
for user in (
|
||||||
user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
|
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
)
|
.first()
|
||||||
for user in User.objects.filter(
|
.users.all()
|
||||||
~Exists(unread_notif_subquery),
|
|
||||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
|
||||||
):
|
):
|
||||||
Notification(
|
if not user.notifications.filter(
|
||||||
user=user,
|
type="MAILING_MODERATION", viewed=False
|
||||||
url=reverse("com:mailing_admin"),
|
).exists():
|
||||||
type="MAILING_MODERATION",
|
Notification(
|
||||||
).save(*args, **kwargs)
|
user=user,
|
||||||
|
url=reverse("com:mailing_admin"),
|
||||||
|
type="MAILING_MODERATION",
|
||||||
|
).save(*args, **kwargs)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
117
club/tests.py
117
club/tests.py
@ -21,7 +21,6 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import localdate, localtime, now
|
from django.utils.timezone import localdate, localtime, now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from model_bakery import baker
|
|
||||||
|
|
||||||
from club.forms import MailingForm
|
from club.forms import MailingForm
|
||||||
from club.models import Club, Mailing, Membership
|
from club.models import Club, Mailing, Membership
|
||||||
@ -165,27 +164,6 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
assert new_mem != "not_member"
|
assert new_mem != "not_member"
|
||||||
assert new_mem.role == 5
|
assert new_mem.role == 5
|
||||||
|
|
||||||
def test_update_change_club_groups(self):
|
|
||||||
"""Test that `update` set the user groups accordingly."""
|
|
||||||
user = baker.make(User)
|
|
||||||
membership = baker.make(Membership, end_date=None, user=user, role=5)
|
|
||||||
members_group = membership.club.members_group
|
|
||||||
board_group = membership.club.board_group
|
|
||||||
assert user.groups.contains(members_group)
|
|
||||||
assert user.groups.contains(board_group)
|
|
||||||
|
|
||||||
user.memberships.update(role=1) # from board to simple member
|
|
||||||
assert user.groups.contains(members_group)
|
|
||||||
assert not user.groups.contains(board_group)
|
|
||||||
|
|
||||||
user.memberships.update(role=5) # from member to board
|
|
||||||
assert user.groups.contains(members_group)
|
|
||||||
assert user.groups.contains(board_group)
|
|
||||||
|
|
||||||
user.memberships.update(end_date=localdate()) # end the membership
|
|
||||||
assert not user.groups.contains(members_group)
|
|
||||||
assert not user.groups.contains(board_group)
|
|
||||||
|
|
||||||
def test_delete_invalidate_cache(self):
|
def test_delete_invalidate_cache(self):
|
||||||
"""Test that the `delete` queryset properly invalidate cache."""
|
"""Test that the `delete` queryset properly invalidate cache."""
|
||||||
mem_skia = self.skia.memberships.get(club=self.club)
|
mem_skia = self.skia.memberships.get(club=self.club)
|
||||||
@ -204,19 +182,6 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
)
|
)
|
||||||
assert cached_mem == "not_member"
|
assert cached_mem == "not_member"
|
||||||
|
|
||||||
def test_delete_remove_from_groups(self):
|
|
||||||
"""Test that `delete` removes from club groups"""
|
|
||||||
user = baker.make(User)
|
|
||||||
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
|
|
||||||
club_groups = {
|
|
||||||
memberships[0].club.members_group,
|
|
||||||
memberships[1].club.members_group,
|
|
||||||
memberships[1].club.board_group,
|
|
||||||
}
|
|
||||||
assert set(user.groups.all()) == club_groups
|
|
||||||
user.memberships.all().delete()
|
|
||||||
assert user.groups.all().count() == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestClubModel(TestClub):
|
class TestClubModel(TestClub):
|
||||||
def assert_membership_started_today(self, user: User, role: int):
|
def assert_membership_started_today(self, user: User, role: int):
|
||||||
@ -227,8 +192,10 @@ class TestClubModel(TestClub):
|
|||||||
assert membership.end_date is None
|
assert membership.end_date is None
|
||||||
assert membership.role == role
|
assert membership.role == role
|
||||||
assert membership.club.get_membership_for(user) == membership
|
assert membership.club.get_membership_for(user) == membership
|
||||||
assert user.is_in_group(pk=self.club.members_group_id)
|
member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||||
assert user.is_in_group(pk=self.club.board_group_id)
|
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)
|
||||||
|
|
||||||
def assert_membership_ended_today(self, user: User):
|
def assert_membership_ended_today(self, user: User):
|
||||||
"""Assert that the given user have a membership which ended today."""
|
"""Assert that the given user have a membership which ended today."""
|
||||||
@ -507,35 +474,37 @@ class TestClubModel(TestClub):
|
|||||||
assert self.club.members.count() == nb_memberships
|
assert self.club.members.count() == nb_memberships
|
||||||
assert membership == new_mem
|
assert membership == new_mem
|
||||||
|
|
||||||
def test_remove_from_club_group(self):
|
def test_delete_remove_from_meta_group(self):
|
||||||
"""Test that when a membership ends, the user is removed from club groups."""
|
"""Test that when a club is deleted, all its members are removed from the
|
||||||
user = baker.make(User)
|
associated metagroup.
|
||||||
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
"""
|
||||||
assert user.groups.contains(self.club.members_group)
|
memberships = self.club.members.select_related("user")
|
||||||
assert user.groups.contains(self.club.board_group)
|
users = [membership.user for membership in memberships]
|
||||||
user.memberships.update(end_date=localdate())
|
meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||||
assert not user.groups.contains(self.club.members_group)
|
|
||||||
assert not user.groups.contains(self.club.board_group)
|
|
||||||
|
|
||||||
def test_add_to_club_group(self):
|
self.club.delete()
|
||||||
"""Test that when a membership begins, the user is added to the club group."""
|
for user in users:
|
||||||
assert not self.subscriber.groups.contains(self.club.members_group)
|
assert not user.is_in_group(name=meta_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_change_position_in_club(self):
|
def test_add_to_meta_group(self):
|
||||||
"""Test that when moving from board to members, club group change"""
|
"""Test that when a membership begins, the user is added to the meta group."""
|
||||||
membership = baker.make(
|
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||||
Membership, club=self.club, user=self.subscriber, role=3
|
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
||||||
)
|
assert not self.subscriber.is_in_group(name=group_members)
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert not self.subscriber.is_in_group(name=board_members)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
Membership.objects.create(club=self.club, user=self.subscriber, role=3)
|
||||||
membership.role = 1
|
assert self.subscriber.is_in_group(name=group_members)
|
||||||
membership.save()
|
assert self.subscriber.is_in_group(name=board_members)
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
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_club_owner(self):
|
def test_club_owner(self):
|
||||||
"""Test that a club is owned only by board members of the main club."""
|
"""Test that a club is owned only by board members of the main club."""
|
||||||
@ -548,26 +517,6 @@ class TestClubModel(TestClub):
|
|||||||
Membership(club=self.ae, user=self.sli, role=3).save()
|
Membership(club=self.ae, user=self.sli, role=3).save()
|
||||||
assert self.club.is_owned_by(self.sli)
|
assert self.club.is_owned_by(self.sli)
|
||||||
|
|
||||||
def test_change_club_name(self):
|
|
||||||
"""Test that changing the club name doesn't break things."""
|
|
||||||
members_group = self.club.members_group
|
|
||||||
board_group = self.club.board_group
|
|
||||||
initial_members = set(members_group.users.values_list("id", flat=True))
|
|
||||||
initial_board = set(board_group.users.values_list("id", flat=True))
|
|
||||||
self.club.name = "something else"
|
|
||||||
self.club.save()
|
|
||||||
self.club.refresh_from_db()
|
|
||||||
|
|
||||||
# The names should have changed, but not the ids nor the group members
|
|
||||||
assert self.club.members_group.name == "something else - Membres"
|
|
||||||
assert self.club.board_group.name == "something else - Bureau"
|
|
||||||
assert self.club.members_group.id == members_group.id
|
|
||||||
assert self.club.board_group.id == board_group.id
|
|
||||||
new_members = set(self.club.members_group.users.values_list("id", flat=True))
|
|
||||||
new_board = set(self.club.board_group.users.values_list("id", flat=True))
|
|
||||||
assert new_members == initial_members
|
|
||||||
assert new_board == initial_board
|
|
||||||
|
|
||||||
|
|
||||||
class TestMailingForm(TestCase):
|
class TestMailingForm(TestCase):
|
||||||
"""Perform validation tests for MailingForm."""
|
"""Perform validation tests for MailingForm."""
|
||||||
|
@ -71,13 +71,14 @@ class ClubTabsMixin(TabedViewMixin):
|
|||||||
return self.object.get_display_name()
|
return self.object.get_display_name()
|
||||||
|
|
||||||
def get_list_of_tabs(self):
|
def get_list_of_tabs(self):
|
||||||
tab_list = [
|
tab_list = []
|
||||||
|
tab_list.append(
|
||||||
{
|
{
|
||||||
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
|
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
|
||||||
"slug": "infos",
|
"slug": "infos",
|
||||||
"name": _("Infos"),
|
"name": _("Infos"),
|
||||||
}
|
}
|
||||||
]
|
)
|
||||||
if self.request.user.can_view(self.object):
|
if self.request.user.can_view(self.object):
|
||||||
tab_list.append(
|
tab_list.append(
|
||||||
{
|
{
|
||||||
|
32
com/api.py
32
com/api.py
@ -1,32 +0,0 @@
|
|||||||
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())
|
|
@ -1,9 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ComConfig(AppConfig):
|
|
||||||
name = "com"
|
|
||||||
verbose_name = "News and communication"
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
import com.signals # noqa F401
|
|
@ -1,76 +0,0 @@
|
|||||||
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
|
|
@ -1,56 +0,0 @@
|
|||||||
# 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,12 +17,11 @@
|
|||||||
# details.
|
# details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU General Public License along with
|
||||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
@ -35,7 +34,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.models import Notification, Preferences, User
|
from core.models import Notification, Preferences, RealGroup, User
|
||||||
|
|
||||||
|
|
||||||
class Sith(models.Model):
|
class Sith(models.Model):
|
||||||
@ -63,31 +62,16 @@ NEWS_TYPES = [
|
|||||||
|
|
||||||
|
|
||||||
class News(models.Model):
|
class News(models.Model):
|
||||||
"""News about club events."""
|
"""The news class."""
|
||||||
|
|
||||||
title = models.CharField(_("title"), max_length=64)
|
title = models.CharField(_("title"), max_length=64)
|
||||||
summary = models.TextField(
|
summary = models.TextField(_("summary"))
|
||||||
_("summary"),
|
content = models.TextField(_("content"))
|
||||||
help_text=_(
|
|
||||||
"A description of the event (what is the activity ? "
|
|
||||||
"is there an associated clic ? is there a inscription form ?)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
content = models.TextField(
|
|
||||||
_("content"),
|
|
||||||
blank=True,
|
|
||||||
default="",
|
|
||||||
help_text=_("A more detailed and exhaustive description of the event."),
|
|
||||||
)
|
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
|
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
|
||||||
)
|
)
|
||||||
club = models.ForeignKey(
|
club = models.ForeignKey(
|
||||||
Club,
|
Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
|
||||||
related_name="news",
|
|
||||||
verbose_name=_("club"),
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
help_text=_("The club which organizes the event."),
|
|
||||||
)
|
)
|
||||||
author = models.ForeignKey(
|
author = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
@ -101,7 +85,7 @@ class News(models.Model):
|
|||||||
related_name="moderated_news",
|
related_name="moderated_news",
|
||||||
verbose_name=_("moderator"),
|
verbose_name=_("moderator"),
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -109,15 +93,17 @@ class News(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
for user in User.objects.filter(
|
for u in (
|
||||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
|
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
|
.first()
|
||||||
|
.users.all()
|
||||||
):
|
):
|
||||||
Notification.objects.create(
|
Notification(
|
||||||
user=user,
|
user=u,
|
||||||
url=reverse("com:news_admin_list"),
|
url=reverse("com:news_admin_list"),
|
||||||
type="NEWS_MODERATION",
|
type="NEWS_MODERATION",
|
||||||
param="1",
|
param="1",
|
||||||
)
|
).save()
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("com:news_detail", kwargs={"news_id": self.id})
|
return reverse("com:news_detail", kwargs={"news_id": self.id})
|
||||||
@ -335,14 +321,16 @@ class Poster(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.is_moderated:
|
if not self.is_moderated:
|
||||||
for user in User.objects.filter(
|
for u in (
|
||||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
|
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
|
.first()
|
||||||
|
.users.all()
|
||||||
):
|
):
|
||||||
Notification.objects.create(
|
Notification(
|
||||||
user=user,
|
user=u,
|
||||||
url=reverse("com:poster_moderate_list"),
|
url=reverse("com:poster_moderate_list"),
|
||||||
type="POSTER_MODERATION",
|
type="POSTER_MODERATION",
|
||||||
)
|
).save()
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
def clean(self, *args, **kwargs):
|
def clean(self, *args, **kwargs):
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
from django.db.models.signals import post_delete, post_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
|
|
||||||
from com.calendar import IcsCalendar
|
|
||||||
from com.models import News
|
|
||||||
|
|
||||||
|
|
||||||
@receiver([post_save, post_delete], sender=News, dispatch_uid="update_internal_ics")
|
|
||||||
def update_internal_ics(*args, **kwargs):
|
|
||||||
_ = IcsCalendar.make_internal()
|
|
@ -1,194 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,101 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,297 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,230 +0,0 @@
|
|||||||
#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,11 +11,6 @@
|
|||||||
{{ gen_news_metatags(news) }}
|
{{ gen_news_metatags(news) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block additional_css %}
|
|
||||||
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
|
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
|
||||||
<section id="news_details">
|
<section id="news_details">
|
||||||
|
@ -34,90 +34,43 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.non_field_errors() }}
|
{{ form.non_field_errors() }}
|
||||||
{{ form.author }}
|
{{ form.author }}
|
||||||
<p>
|
<p>{{ form.type.errors }}<label for="{{ form.type.name }}">{{ form.type.label }}</label>
|
||||||
{{ form.type.errors }}
|
|
||||||
<label for="{{ form.type.name }}" class="required">{{ form.type.label }}</label>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
|
<li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
|
||||||
<li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
|
<li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
|
||||||
<li>
|
<li>{% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}</li>
|
||||||
{% trans trimmed%}
|
<li>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
|
||||||
Weekly: recurrent event, associated with many dates
|
|
||||||
(specify the first one, and a deadline)
|
|
||||||
{% endtrans %}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
{% trans trimmed %}
|
|
||||||
Call: long time event, associated with a long date (like election appliance)
|
|
||||||
{% endtrans %}
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
{{ form.type }}
|
{{ form.type }}</p>
|
||||||
</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">
|
<p class="date">{{ form.end_date.errors }}<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label> {{ form.end_date }}</p>
|
||||||
{{ form.start_date.errors }}
|
<p class="until">{{ form.until.errors }}<label for="{{ form.until.name }}">{{ form.until.label }}</label> {{ form.until }}</p>
|
||||||
<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
|
<p>{{ form.title.errors }}<label for="{{ form.title.name }}">{{ form.title.label }}</label> {{ form.title }}</p>
|
||||||
{{ form.start_date }}
|
<p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
|
||||||
</p>
|
<p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
|
||||||
<p class="date">
|
<p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
|
||||||
{{ form.end_date.errors }}
|
|
||||||
<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label>
|
|
||||||
{{ form.end_date }}
|
|
||||||
</p>
|
|
||||||
<p class="until">
|
|
||||||
{{ form.until.errors }}
|
|
||||||
<label for="{{ form.until.name }}">{{ form.until.label }}</label>
|
|
||||||
{{ form.until }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{{ form.title.errors }}
|
|
||||||
<label for="{{ form.title.name }}" class="required">{{ form.title.label }}</label>
|
|
||||||
{{ form.title }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{{ form.club.errors }}
|
|
||||||
<label for="{{ form.club.name }}" class="required">{{ form.club.label }}</label>
|
|
||||||
<span class="helptext">{{ form.club.help_text }}</span>
|
|
||||||
{{ form.club }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{{ form.summary.errors }}
|
|
||||||
<label for="{{ form.summary.name }}" class="required">{{ form.summary.label }}</label>
|
|
||||||
<span class="helptext">{{ form.summary.help_text }}</span>
|
|
||||||
{{ form.summary }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{{ form.content.errors }}
|
|
||||||
<label for="{{ form.content.name }}">{{ form.content.label }}</label>
|
|
||||||
<span class="helptext">{{ form.content.help_text }}</span>
|
|
||||||
{{ form.content }}
|
|
||||||
</p>
|
|
||||||
{% if user.is_com_admin %}
|
{% if user.is_com_admin %}
|
||||||
<p>
|
<p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
|
||||||
{{ form.automoderation.errors }}
|
{{ form.automoderation }}</p>
|
||||||
<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
|
|
||||||
{{ form.automoderation }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
|
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}" /></p>
|
||||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
|
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
$(function () {
|
$( function() {
|
||||||
let type = $('input[name=type]');
|
var type = $('input[name=type]');
|
||||||
let dates = $('.date');
|
var dates = $('.date');
|
||||||
let until = $('.until');
|
var until = $('.until');
|
||||||
|
function update_targets () {
|
||||||
function update_targets() {
|
type_checked = $('input[name=type]:checked');
|
||||||
const type_checked = $('input[name=type]:checked');
|
if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") {
|
||||||
if (["CALL", "EVENT"].includes(type_checked.val())) {
|
|
||||||
dates.show();
|
dates.show();
|
||||||
until.hide();
|
until.hide();
|
||||||
} else if (type_checked.val() === "WEEKLY") {
|
} else if (type_checked.val() == "WEEKLY") {
|
||||||
dates.show();
|
dates.show();
|
||||||
until.show();
|
until.show();
|
||||||
} else {
|
} else {
|
||||||
@ -125,10 +78,9 @@
|
|||||||
until.hide();
|
until.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update_targets();
|
update_targets();
|
||||||
type.change(update_targets);
|
type.change(update_targets);
|
||||||
});
|
} );
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -5,15 +5,6 @@
|
|||||||
{% trans %}News{% endtrans %}
|
{% trans %}News{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_css %}
|
|
||||||
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
|
|
||||||
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block additional_js %}
|
|
||||||
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if user.is_com_admin %}
|
{% if user.is_com_admin %}
|
||||||
<div id="news_admin">
|
<div id="news_admin">
|
||||||
@ -92,78 +83,84 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% set coming_soon = object_list.filter(dates__start_date__gte=timezone.now()+timedelta(days=5),
|
||||||
|
type="EVENT").order_by('dates__start_date') %}
|
||||||
|
{% if coming_soon %}
|
||||||
|
<h3>{% trans %}Coming soon... don't miss!{% endtrans %}</h3>
|
||||||
|
{% for news in coming_soon %}
|
||||||
|
<section class="news_coming_soon">
|
||||||
|
<a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a>
|
||||||
|
<span class="news_date">{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
|
||||||
|
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
|
||||||
|
{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
|
||||||
|
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h3>{% trans %}All coming events{% endtrans %}</h3>
|
<h3>{% trans %}All coming events{% endtrans %}</h3>
|
||||||
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="right_column">
|
<div id="right_column" class="news_column">
|
||||||
<div id="links">
|
<div id="agenda">
|
||||||
<h3>{% trans %}Links{% endtrans %}</h3>
|
<div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
|
||||||
<div id="links_content">
|
<div id="agenda_content">
|
||||||
<h4>{% trans %}Our services{% endtrans %}</h4>
|
{% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
|
||||||
<ul>
|
news__is_moderated=True, news__type__in=["WEEKLY",
|
||||||
<li>
|
"EVENT"]).order_by('start_date', 'end_date') %}
|
||||||
<i class="fa-solid fa-graduation-cap fa-xl"></i>
|
<div class="agenda_item">
|
||||||
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
|
<div class="agenda_date">
|
||||||
</li>
|
<strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
|
||||||
<li>
|
</div>
|
||||||
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
<div class="agenda_time">
|
||||||
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
<span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
|
||||||
</li>
|
<span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
|
||||||
<li>
|
</div>
|
||||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
<div>
|
||||||
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
|
<strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
|
||||||
</li>
|
<a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
|
||||||
</ul>
|
</div>
|
||||||
<br>
|
<div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
|
||||||
<h4>{% trans %}Social media{% endtrans %}</h4>
|
</div>
|
||||||
<ul>
|
{% endfor %}
|
||||||
<li>
|
|
||||||
<i class="fa-brands fa-discord fa-xl"></i>
|
|
||||||
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
|
|
||||||
{% if user.was_subscribed %}
|
|
||||||
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">{% trans %}Dev Team{% endtrans %}</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<i class="fa-brands fa-facebook fa-xl"></i>
|
|
||||||
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<i class="fa-brands fa-square-instagram fa-xl"></i>
|
|
||||||
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="birthdays">
|
<div id="birthdays">
|
||||||
<h3>{% trans %}Birthdays{% endtrans %}</h3>
|
<div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
|
||||||
<div id="birthdays_content">
|
<div id="birthdays_content">
|
||||||
{%- if user.was_subscribed -%}
|
{% if user.is_subscribed %}
|
||||||
<ul class="birthdays_year">
|
{# Cache request for 1 hour #}
|
||||||
{%- for year, users in birthdays -%}
|
{% cache 3600 "birthdays" %}
|
||||||
<li>
|
<ul class="birthdays_year">
|
||||||
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
|
{% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
|
||||||
<ul>
|
<li>
|
||||||
{%- for u in users -%}
|
{% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
|
||||||
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
|
<ul>
|
||||||
{%- endfor -%}
|
{% for u in birthdays.filter(date_of_birth__year=d.year) %}
|
||||||
</ul>
|
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
|
||||||
</li>
|
{% endfor %}
|
||||||
{%- endfor -%}
|
</ul>
|
||||||
</ul>
|
</li>
|
||||||
{%- else -%}
|
{% endfor %}
|
||||||
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
|
</ul>
|
||||||
{%- endif -%}
|
{% endcache %}
|
||||||
|
{% else %}
|
||||||
|
<p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,10 +10,6 @@
|
|||||||
{% trans %}Poster{% endtrans %}
|
{% trans %}Poster{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_css %}
|
|
||||||
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="poster_list">
|
<div id="poster_list">
|
||||||
|
|
||||||
|
@ -5,10 +5,6 @@
|
|||||||
<script src="{{ static('com/js/poster_list.js') }}"></script>
|
<script src="{{ static('com/js/poster_list.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_css %}
|
|
||||||
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="poster_list">
|
<div id="poster_list">
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>{% trans %}Slideshow{% endtrans %}</title>
|
<title>{% trans %}Slideshow{% endtrans %}</title>
|
||||||
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
||||||
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
|
<script type="module" src="{{ static('bundled/jquery-index.js') }}"></script>
|
||||||
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -23,7 +23,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
|
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
|
||||||
from core.models import AnonymousUser, Group, User
|
from core.models import AnonymousUser, RealGroup, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@ -49,7 +49,9 @@ class TestCom(TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.skia = User.objects.get(username="skia")
|
cls.skia = User.objects.get(username="skia")
|
||||||
cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
cls.com_group = RealGroup.objects.filter(
|
||||||
|
id=settings.SITH_GROUP_COM_ADMIN_ID
|
||||||
|
).first()
|
||||||
cls.skia.groups.set([cls.com_group])
|
cls.skia.groups.set([cls.com_group])
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -97,7 +99,9 @@ class TestCom(TestCase):
|
|||||||
response = self.client.get(reverse("core:index"))
|
response = self.client.get(reverse("core:index"))
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
response,
|
response,
|
||||||
text=html.escape(_("You need to subscribe to access this content")),
|
text=html.escape(
|
||||||
|
_("You need an up to date subscription to access this content")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_birthday_subscibed_user(self):
|
def test_birthday_subscibed_user(self):
|
||||||
@ -105,16 +109,9 @@ class TestCom(TestCase):
|
|||||||
|
|
||||||
self.assertNotContains(
|
self.assertNotContains(
|
||||||
response,
|
response,
|
||||||
text=html.escape(_("You need to subscribe to access this content")),
|
text=html.escape(
|
||||||
)
|
_("You need an up to date subscription to access this content")
|
||||||
|
),
|
||||||
def test_birthday_old_subscibed_user(self):
|
|
||||||
self.client.force_login(User.objects.get(username="old_subscriber"))
|
|
||||||
response = self.client.get(reverse("core:index"))
|
|
||||||
|
|
||||||
self.assertNotContains(
|
|
||||||
response,
|
|
||||||
text=html.escape(_("You need to subscribe to access this content")),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
|||||||
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()
|
|
73
com/views.py
73
com/views.py
@ -21,14 +21,14 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
import itertools
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from smtplib import SMTPRecipientsRefused
|
from smtplib import SMTPRecipientsRefused
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.db.models import Exists, Max, OuterRef
|
from django.db.models import Max
|
||||||
from django.forms.models import modelform_factory
|
from django.forms.models import modelform_factory
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
@ -42,7 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
|||||||
|
|
||||||
from club.models import Club, Mailing
|
from club.models import Club, Mailing
|
||||||
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
|
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
|
||||||
from core.models import Notification, User
|
from core.models import Notification, RealGroup, User
|
||||||
from core.views import (
|
from core.views import (
|
||||||
CanCreateMixin,
|
CanCreateMixin,
|
||||||
CanEditMixin,
|
CanEditMixin,
|
||||||
@ -223,13 +223,15 @@ class NewsForm(forms.ModelForm):
|
|||||||
):
|
):
|
||||||
self.add_error(
|
self.add_error(
|
||||||
"end_date",
|
"end_date",
|
||||||
ValidationError(_("An event cannot end before its beginning.")),
|
ValidationError(
|
||||||
|
_("You crazy? You can not finish an event before starting it.")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
|
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
|
||||||
self.add_error("until", ValidationError(_("This field is required.")))
|
self.add_error("until", ValidationError(_("This field is required.")))
|
||||||
return self.cleaned_data
|
return self.cleaned_data
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self):
|
||||||
ret = super().save()
|
ret = super().save()
|
||||||
self.instance.dates.all().delete()
|
self.instance.dates.all().delete()
|
||||||
if self.instance.type == "EVENT" or self.instance.type == "CALL":
|
if self.instance.type == "EVENT" or self.instance.type == "CALL":
|
||||||
@ -278,18 +280,21 @@ class NewsEditView(CanEditMixin, UpdateView):
|
|||||||
else:
|
else:
|
||||||
self.object.is_moderated = False
|
self.object.is_moderated = False
|
||||||
self.object.save()
|
self.object.save()
|
||||||
unread_notif_subquery = Notification.objects.filter(
|
for u in (
|
||||||
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
|
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
)
|
.first()
|
||||||
for user in User.objects.filter(
|
.users.all()
|
||||||
~Exists(unread_notif_subquery),
|
|
||||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
|
||||||
):
|
):
|
||||||
Notification.objects.create(
|
if not u.notifications.filter(
|
||||||
user=user,
|
type="NEWS_MODERATION", viewed=False
|
||||||
url=self.object.get_absolute_url(),
|
).exists():
|
||||||
type="NEWS_MODERATION",
|
Notification(
|
||||||
)
|
user=u,
|
||||||
|
url=reverse(
|
||||||
|
"com:news_detail", kwargs={"news_id": self.object.id}
|
||||||
|
),
|
||||||
|
type="NEWS_MODERATION",
|
||||||
|
).save()
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
@ -320,18 +325,19 @@ class NewsCreateView(CanCreateMixin, CreateView):
|
|||||||
self.object.is_moderated = True
|
self.object.is_moderated = True
|
||||||
self.object.save()
|
self.object.save()
|
||||||
else:
|
else:
|
||||||
unread_notif_subquery = Notification.objects.filter(
|
for u in (
|
||||||
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
|
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
)
|
.first()
|
||||||
for user in User.objects.filter(
|
.users.all()
|
||||||
~Exists(unread_notif_subquery),
|
|
||||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
|
||||||
):
|
):
|
||||||
Notification.objects.create(
|
if not u.notifications.filter(
|
||||||
user=user,
|
type="NEWS_MODERATION", viewed=False
|
||||||
url=reverse("com:news_admin_list"),
|
).exists():
|
||||||
type="NEWS_MODERATION",
|
Notification(
|
||||||
)
|
user=u,
|
||||||
|
url=reverse("com:news_admin_list"),
|
||||||
|
type="NEWS_MODERATION",
|
||||||
|
).save()
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
@ -374,14 +380,13 @@ class NewsListView(CanViewMixin, ListView):
|
|||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["NewsDate"] = NewsDate
|
kwargs["NewsDate"] = NewsDate
|
||||||
kwargs["timedelta"] = timedelta
|
kwargs["timedelta"] = timedelta
|
||||||
kwargs["birthdays"] = itertools.groupby(
|
kwargs["birthdays"] = (
|
||||||
User.objects.filter(
|
User.objects.filter(
|
||||||
date_of_birth__month=localdate().month,
|
date_of_birth__month=localdate().month,
|
||||||
date_of_birth__day=localdate().day,
|
date_of_birth__day=localdate().day,
|
||||||
)
|
)
|
||||||
.filter(role__in=["STUDENT", "FORMER STUDENT"])
|
.filter(role__in=["STUDENT", "FORMER STUDENT"])
|
||||||
.order_by("-date_of_birth"),
|
.order_by("-date_of_birth")
|
||||||
key=lambda u: u.date_of_birth.year,
|
|
||||||
)
|
)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@ -685,12 +690,8 @@ class PosterEditBaseView(UpdateView):
|
|||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
return {
|
return {
|
||||||
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
|
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
if self.object.date_begin
|
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
else None,
|
|
||||||
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
if self.object.date_end
|
|
||||||
else None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
@ -15,32 +15,17 @@
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.models import Group as AuthGroup
|
from django.contrib.auth.models import Group as AuthGroup
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
|
|
||||||
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
|
from core.models import Group, OperationLog, Page, SithFile, User
|
||||||
|
|
||||||
admin.site.unregister(AuthGroup)
|
admin.site.unregister(AuthGroup)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Group)
|
@admin.register(Group)
|
||||||
class GroupAdmin(admin.ModelAdmin):
|
class GroupAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "description", "is_manually_manageable")
|
list_display = ("name", "description", "is_meta")
|
||||||
list_filter = ("is_manually_manageable",)
|
list_filter = ("is_meta",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
autocomplete_fields = ("permissions",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BanGroup)
|
|
||||||
class BanGroupAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "description")
|
|
||||||
search_fields = ("name",)
|
|
||||||
autocomplete_fields = ("permissions",)
|
|
||||||
|
|
||||||
|
|
||||||
class UserBanInline(admin.TabularInline):
|
|
||||||
model = UserBan
|
|
||||||
extra = 0
|
|
||||||
autocomplete_fields = ("ban_group",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
@ -52,24 +37,10 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
"profile_pict",
|
"profile_pict",
|
||||||
"avatar_pict",
|
"avatar_pict",
|
||||||
"scrub_pict",
|
"scrub_pict",
|
||||||
"user_permissions",
|
|
||||||
"groups",
|
|
||||||
)
|
)
|
||||||
inlines = (UserBanInline,)
|
|
||||||
search_fields = ["first_name", "last_name", "username"]
|
search_fields = ["first_name", "last_name", "username"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(UserBan)
|
|
||||||
class UserBanAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("user", "ban_group", "created_at", "expires_at")
|
|
||||||
autocomplete_fields = ("user", "ban_group")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Permission)
|
|
||||||
class PermissionAdmin(admin.ModelAdmin):
|
|
||||||
search_fields = ("codename",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Page)
|
@admin.register(Page)
|
||||||
class PageAdmin(admin.ModelAdmin):
|
class PageAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "_full_name", "owner_group")
|
list_display = ("name", "_full_name", "owner_group")
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.backends import ModelBackend
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
|
|
||||||
from core.models import Group
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from core.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class SithModelBackend(ModelBackend):
|
|
||||||
"""Custom auth backend for the Sith.
|
|
||||||
|
|
||||||
In fact, it's the exact same backend as `django.contrib.auth.backend.ModelBackend`,
|
|
||||||
with the exception that group permissions are fetched slightly differently.
|
|
||||||
Indeed, django tries by default to fetch the permissions associated
|
|
||||||
with all the `django.contrib.auth.models.Group` of a user ;
|
|
||||||
however, our User model overrides that, so the actual linked group model
|
|
||||||
is [core.models.Group][].
|
|
||||||
Instead of having the relation `auth_perm --> auth_group <-- core_user`,
|
|
||||||
we have `auth_perm --> auth_group <-- core_group <-- core_user`.
|
|
||||||
|
|
||||||
Thus, this backend make the small tweaks necessary to make
|
|
||||||
our custom models interact with the django auth.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _get_group_permissions(self, user_obj: User):
|
|
||||||
# union of querysets doesn't work if the queryset is ordered.
|
|
||||||
# The empty `order_by` here are actually there to *remove*
|
|
||||||
# any default ordering defined in managers or model Meta
|
|
||||||
groups = user_obj.groups.order_by()
|
|
||||||
if user_obj.is_subscribed:
|
|
||||||
groups = groups.union(
|
|
||||||
Group.objects.filter(pk=settings.SITH_GROUP_SUBSCRIBERS_ID).order_by()
|
|
||||||
)
|
|
||||||
return Permission.objects.filter(
|
|
||||||
group__group__in=groups.values_list("pk", flat=True)
|
|
||||||
)
|
|
@ -7,7 +7,7 @@ from model_bakery import seq
|
|||||||
from model_bakery.recipe import Recipe, related
|
from model_bakery.recipe import Recipe, related
|
||||||
|
|
||||||
from club.models import Membership
|
from club.models import Membership
|
||||||
from core.models import Group, User
|
from core.models import User
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
active_subscription = Recipe(
|
active_subscription = Recipe(
|
||||||
@ -60,6 +60,5 @@ board_user = Recipe(
|
|||||||
first_name="AE",
|
first_name="AE",
|
||||||
last_name=seq("member "),
|
last_name=seq("member "),
|
||||||
memberships=related(ae_board_membership),
|
memberships=related(ae_board_membership),
|
||||||
groups=lambda: [Group.objects.get(club_board=settings.SITH_MAIN_CLUB_ID)],
|
|
||||||
)
|
)
|
||||||
"""A user which is in the board of the AE."""
|
"""A user which is in the board of the AE."""
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, NamedTuple
|
from typing import ClassVar
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@ -31,7 +31,6 @@ from django.contrib.sites.models import Site
|
|||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@ -46,9 +45,8 @@ from accounting.models import (
|
|||||||
SimplifiedAccountingType,
|
SimplifiedAccountingType,
|
||||||
)
|
)
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from com.calendar import IcsCalendar
|
|
||||||
from com.models import News, NewsDate, Sith, Weekmail
|
from com.models import News, NewsDate, Sith, Weekmail
|
||||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
from core.models import Group, Page, PageRev, RealGroup, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
from counter.models import Counter, Product, ProductType, StudentCard
|
from counter.models import Counter, Product, ProductType, StudentCard
|
||||||
from election.models import Candidature, Election, ElectionList, Role
|
from election.models import Candidature, Election, ElectionList, Role
|
||||||
@ -58,18 +56,6 @@ from sas.models import Album, PeoplePictureRelation, Picture
|
|||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
class PopulatedGroups(NamedTuple):
|
|
||||||
root: Group
|
|
||||||
public: Group
|
|
||||||
subscribers: Group
|
|
||||||
old_subscribers: Group
|
|
||||||
sas_admin: Group
|
|
||||||
com_admin: Group
|
|
||||||
counter_admin: Group
|
|
||||||
accounting_admin: Group
|
|
||||||
pedagogy_admin: Group
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||||
@ -83,7 +69,7 @@ class Command(BaseCommand):
|
|||||||
# sqlite doesn't support this operation
|
# sqlite doesn't support this operation
|
||||||
return
|
return
|
||||||
sqlcmd = StringIO()
|
sqlcmd = StringIO()
|
||||||
call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
|
call_command("sqlsequencereset", *args, stdout=sqlcmd)
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
cursor.execute(sqlcmd.getvalue())
|
cursor.execute(sqlcmd.getvalue())
|
||||||
|
|
||||||
@ -93,8 +79,25 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
|
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
|
||||||
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
|
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
|
||||||
groups = self._create_groups()
|
|
||||||
self._create_ban_groups()
|
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)
|
||||||
|
|
||||||
root = User.objects.create_superuser(
|
root = User.objects.create_superuser(
|
||||||
id=0,
|
id=0,
|
||||||
@ -134,10 +137,11 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.reset_index("club")
|
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 = [
|
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="Eboutic", club=main_club, type="EBOUTIC"),
|
||||||
Counter(name="AE", club=main_club, type="OFFICE"),
|
Counter(name="AE", club=main_club, type="OFFICE"),
|
||||||
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
||||||
@ -145,16 +149,14 @@ class Command(BaseCommand):
|
|||||||
Counter.objects.bulk_create(counters)
|
Counter.objects.bulk_create(counters)
|
||||||
bar_groups = []
|
bar_groups = []
|
||||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||||
group = Group.objects.create(
|
group = RealGroup.objects.create(name=f"{bar_name} admin")
|
||||||
name=f"{bar_name} admin", is_manually_manageable=True
|
|
||||||
)
|
|
||||||
bar_groups.append(
|
bar_groups.append(
|
||||||
Counter.edit_groups.through(counter_id=bar_id, group=group)
|
Counter.edit_groups.through(counter_id=bar_id, group=group)
|
||||||
)
|
)
|
||||||
Counter.edit_groups.through.objects.bulk_create(bar_groups)
|
Counter.edit_groups.through.objects.bulk_create(bar_groups)
|
||||||
self.reset_index("counter")
|
self.reset_index("counter")
|
||||||
|
|
||||||
groups.subscribers.viewable_files.add(home_root, club_root)
|
subscribers.viewable_files.add(home_root, club_root)
|
||||||
|
|
||||||
Weekmail().save()
|
Weekmail().save()
|
||||||
|
|
||||||
@ -259,11 +261,21 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
User.groups.through.objects.bulk_create(
|
User.groups.through.objects.bulk_create(
|
||||||
[
|
[
|
||||||
User.groups.through(group=groups.counter_admin, user=counter),
|
User.groups.through(
|
||||||
User.groups.through(group=groups.accounting_admin, user=comptable),
|
realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
|
||||||
User.groups.through(group=groups.com_admin, user=comunity),
|
),
|
||||||
User.groups.through(group=groups.pedagogy_admin, user=tutu),
|
User.groups.through(
|
||||||
User.groups.through(group=groups.sas_admin, user=skia),
|
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
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
for user in richard, sli, krophil, skia:
|
for user in richard, sli, krophil, skia:
|
||||||
@ -324,7 +336,7 @@ Welcome to the wiki page!
|
|||||||
content="Fonctionnement de la laverie",
|
content="Fonctionnement de la laverie",
|
||||||
)
|
)
|
||||||
|
|
||||||
groups.public.viewable_page.set(
|
public_group.viewable_page.set(
|
||||||
[syntax_page, services_page, index_page, laundry_page]
|
[syntax_page, services_page, index_page, laundry_page]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -370,42 +382,46 @@ Welcome to the wiki page!
|
|||||||
parent=main_club,
|
parent=main_club,
|
||||||
)
|
)
|
||||||
|
|
||||||
Membership.objects.create(user=skia, club=main_club, role=3)
|
Membership.objects.bulk_create(
|
||||||
Membership.objects.create(
|
[
|
||||||
user=comunity,
|
Membership(user=skia, club=main_club, role=3),
|
||||||
club=bar_club,
|
Membership(
|
||||||
start_date=localdate(),
|
user=comunity,
|
||||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
club=bar_club,
|
||||||
)
|
start_date=localdate(),
|
||||||
Membership.objects.create(
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
user=sli,
|
),
|
||||||
club=troll,
|
Membership(
|
||||||
role=9,
|
user=sli,
|
||||||
description="Padawan Troll",
|
club=troll,
|
||||||
start_date=localdate() - timedelta(days=17),
|
role=9,
|
||||||
)
|
description="Padawan Troll",
|
||||||
Membership.objects.create(
|
start_date=localdate() - timedelta(days=17),
|
||||||
user=krophil,
|
),
|
||||||
club=troll,
|
Membership(
|
||||||
role=10,
|
user=krophil,
|
||||||
description="Maitre Troll",
|
club=troll,
|
||||||
start_date=localdate() - timedelta(days=200),
|
role=10,
|
||||||
)
|
description="Maitre Troll",
|
||||||
Membership.objects.create(
|
start_date=localdate() - timedelta(days=200),
|
||||||
user=skia,
|
),
|
||||||
club=troll,
|
Membership(
|
||||||
role=2,
|
user=skia,
|
||||||
description="Grand Ancien Troll",
|
club=troll,
|
||||||
start_date=localdate() - timedelta(days=400),
|
role=2,
|
||||||
end_date=localdate() - timedelta(days=86),
|
description="Grand Ancien Troll",
|
||||||
)
|
start_date=localdate() - timedelta(days=400),
|
||||||
Membership.objects.create(
|
end_date=localdate() - timedelta(days=86),
|
||||||
user=richard,
|
),
|
||||||
club=troll,
|
Membership(
|
||||||
role=2,
|
user=richard,
|
||||||
description="",
|
club=troll,
|
||||||
start_date=localdate() - timedelta(days=200),
|
role=2,
|
||||||
end_date=localdate() - timedelta(days=100),
|
description="",
|
||||||
|
start_date=localdate() - timedelta(days=200),
|
||||||
|
end_date=localdate() - timedelta(days=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
p = ProductType.objects.create(name="Bières bouteilles")
|
p = ProductType.objects.create(name="Bières bouteilles")
|
||||||
@ -460,7 +476,6 @@ Welcome to the wiki page!
|
|||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
cons = Product.objects.create(
|
cons = Product.objects.create(
|
||||||
id=settings.SITH_ECOCUP_CONS,
|
|
||||||
name="Consigne Eco-cup",
|
name="Consigne Eco-cup",
|
||||||
code="CONS",
|
code="CONS",
|
||||||
product_type=verre,
|
product_type=verre,
|
||||||
@ -470,7 +485,6 @@ Welcome to the wiki page!
|
|||||||
club=main_club,
|
club=main_club,
|
||||||
)
|
)
|
||||||
dcons = Product.objects.create(
|
dcons = Product.objects.create(
|
||||||
id=settings.SITH_ECOCUP_DECO,
|
|
||||||
name="Déconsigne Eco-cup",
|
name="Déconsigne Eco-cup",
|
||||||
code="DECO",
|
code="DECO",
|
||||||
product_type=verre,
|
product_type=verre,
|
||||||
@ -499,10 +513,8 @@ Welcome to the wiki page!
|
|||||||
club=main_club,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
groups.subscribers.products.add(
|
subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
|
||||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
old_subscribers.products.add(cotis, cotis2)
|
||||||
)
|
|
||||||
groups.old_subscribers.products.add(cotis, cotis2)
|
|
||||||
|
|
||||||
mde = Counter.objects.get(name="MDE")
|
mde = Counter.objects.get(name="MDE")
|
||||||
mde.products.add(barb, cble, cons, dcons)
|
mde.products.add(barb, cble, cons, dcons)
|
||||||
@ -596,6 +608,7 @@ Welcome to the wiki page!
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create an election
|
# Create an election
|
||||||
|
ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
|
||||||
el = Election.objects.create(
|
el = Election.objects.create(
|
||||||
title="Élection 2017",
|
title="Élection 2017",
|
||||||
description="La roue tourne",
|
description="La roue tourne",
|
||||||
@ -604,10 +617,10 @@ Welcome to the wiki page!
|
|||||||
start_date="1942-06-12 10:28:45+01",
|
start_date="1942-06-12 10:28:45+01",
|
||||||
end_date="7942-06-12 10:28:45+01",
|
end_date="7942-06-12 10:28:45+01",
|
||||||
)
|
)
|
||||||
el.view_groups.add(groups.public)
|
el.view_groups.add(public_group)
|
||||||
el.edit_groups.add(main_club.board_group)
|
el.edit_groups.add(ae_board_group)
|
||||||
el.candidature_groups.add(groups.subscribers)
|
el.candidature_groups.add(subscribers)
|
||||||
el.vote_groups.add(groups.subscribers)
|
el.vote_groups.add(subscribers)
|
||||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||||
listeT = ElectionList.objects.create(title="Troll", election=el)
|
listeT = ElectionList.objects.create(title="Troll", election=el)
|
||||||
pres = Role.objects.create(
|
pres = Role.objects.create(
|
||||||
@ -742,7 +755,7 @@ Welcome to the wiki page!
|
|||||||
NewsDate(
|
NewsDate(
|
||||||
news=n,
|
news=n,
|
||||||
start_date=friday + timedelta(hours=24 * 7 + 1),
|
start_date=friday + timedelta(hours=24 * 7 + 1),
|
||||||
end_date=friday + timedelta(hours=24 * 7 + 9),
|
end_date=self.now + timedelta(hours=24 * 7 + 9),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Weekly
|
# Weekly
|
||||||
@ -768,9 +781,8 @@ Welcome to the wiki page!
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
NewsDate.objects.bulk_create(news_dates)
|
NewsDate.objects.bulk_create(news_dates)
|
||||||
IcsCalendar.make_internal() # Force refresh of the calendar after a bulk_create
|
|
||||||
|
|
||||||
# Create some data for pedagogy
|
# Create som data for pedagogy
|
||||||
|
|
||||||
UV(
|
UV(
|
||||||
code="PA00",
|
code="PA00",
|
||||||
@ -887,114 +899,3 @@ Welcome to the wiki page!
|
|||||||
start=s.subscription_start,
|
start=s.subscription_start,
|
||||||
)
|
)
|
||||||
s.save()
|
s.save()
|
||||||
|
|
||||||
def _create_groups(self) -> PopulatedGroups:
|
|
||||||
perms = Permission.objects.all()
|
|
||||||
|
|
||||||
root_group = Group.objects.create(name="Root", is_manually_manageable=True)
|
|
||||||
root_group.permissions.add(*list(perms.values_list("pk", flat=True)))
|
|
||||||
# public has no permission.
|
|
||||||
# Its purpose is not to link users to permissions,
|
|
||||||
# but to other objects (like products)
|
|
||||||
public_group = Group.objects.create(name="Public")
|
|
||||||
|
|
||||||
subscribers = Group.objects.create(name="Subscribers")
|
|
||||||
old_subscribers = Group.objects.create(name="Old subscribers")
|
|
||||||
old_subscribers.permissions.add(
|
|
||||||
*list(
|
|
||||||
perms.filter(
|
|
||||||
codename__in=[
|
|
||||||
"view_user",
|
|
||||||
"view_picture",
|
|
||||||
"view_album",
|
|
||||||
"view_peoplepicturerelation",
|
|
||||||
"add_peoplepicturerelation",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
accounting_admin = Group.objects.create(
|
|
||||||
name="Accounting admin", is_manually_manageable=True
|
|
||||||
)
|
|
||||||
accounting_admin.permissions.add(
|
|
||||||
*list(
|
|
||||||
perms.filter(
|
|
||||||
Q(content_type__app_label="accounting")
|
|
||||||
| Q(
|
|
||||||
codename__in=[
|
|
||||||
"view_customer",
|
|
||||||
"view_product",
|
|
||||||
"change_product",
|
|
||||||
"add_product",
|
|
||||||
"view_producttype",
|
|
||||||
"change_producttype",
|
|
||||||
"add_producttype",
|
|
||||||
"delete_selling",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
).values_list("pk", flat=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
com_admin = Group.objects.create(
|
|
||||||
name="Communication admin", is_manually_manageable=True
|
|
||||||
)
|
|
||||||
com_admin.permissions.add(
|
|
||||||
*list(
|
|
||||||
perms.filter(content_type__app_label="com").values_list("pk", flat=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
counter_admin = Group.objects.create(
|
|
||||||
name="Counter admin", is_manually_manageable=True
|
|
||||||
)
|
|
||||||
counter_admin.permissions.add(
|
|
||||||
*list(
|
|
||||||
perms.filter(
|
|
||||||
Q(content_type__app_label__in=["counter", "launderette"])
|
|
||||||
& ~Q(codename__in=["delete_product", "delete_producttype"])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
|
|
||||||
sas_admin.permissions.add(
|
|
||||||
*list(
|
|
||||||
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
forum_admin = Group.objects.create(
|
|
||||||
name="Forum admin", is_manually_manageable=True
|
|
||||||
)
|
|
||||||
forum_admin.permissions.add(
|
|
||||||
*list(
|
|
||||||
perms.filter(content_type__app_label="forum").values_list(
|
|
||||||
"pk", flat=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pedagogy_admin = Group.objects.create(
|
|
||||||
name="Pedagogy admin", is_manually_manageable=True
|
|
||||||
)
|
|
||||||
pedagogy_admin.permissions.add(
|
|
||||||
*list(
|
|
||||||
perms.filter(content_type__app_label="pedagogy").values_list(
|
|
||||||
"pk", flat=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.reset_index("core", "auth")
|
|
||||||
|
|
||||||
return PopulatedGroups(
|
|
||||||
root=root_group,
|
|
||||||
public=public_group,
|
|
||||||
subscribers=subscribers,
|
|
||||||
old_subscribers=old_subscribers,
|
|
||||||
com_admin=com_admin,
|
|
||||||
counter_admin=counter_admin,
|
|
||||||
accounting_admin=accounting_admin,
|
|
||||||
sas_admin=sas_admin,
|
|
||||||
pedagogy_admin=pedagogy_admin,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_ban_groups(self):
|
|
||||||
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
|
||||||
BanGroup.objects.create(name="Banned from counters", description="")
|
|
||||||
BanGroup.objects.create(name="Banned to subscribe", description="")
|
|
||||||
|
@ -11,7 +11,7 @@ from django.utils.timezone import localdate, make_aware, now
|
|||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from core.models import Group, User
|
from core.models import RealGroup, User
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
@ -173,8 +173,7 @@ class Command(BaseCommand):
|
|||||||
club=club,
|
club=club,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
memberships = Membership.objects.bulk_create(memberships)
|
Membership.objects.bulk_create(memberships)
|
||||||
Membership._add_club_groups(memberships)
|
|
||||||
|
|
||||||
def create_uvs(self):
|
def create_uvs(self):
|
||||||
root = User.objects.get(username="root")
|
root = User.objects.get(username="root")
|
||||||
@ -226,7 +225,9 @@ class Command(BaseCommand):
|
|||||||
ae = Club.objects.get(unix_name="ae")
|
ae = Club.objects.get(unix_name="ae")
|
||||||
other_clubs = random.sample(list(Club.objects.all()), k=3)
|
other_clubs = random.sample(list(Club.objects.all()), k=3)
|
||||||
groups = list(
|
groups = list(
|
||||||
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
|
RealGroup.objects.filter(
|
||||||
|
name__in=["Subscribers", "Old subscribers", "Public"]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
counters = list(
|
counters = list(
|
||||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
|
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
|
||||||
|
@ -16,7 +16,6 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -30,7 +29,7 @@ class Command(BaseCommand):
|
|||||||
if not data_dir.is_dir():
|
if not data_dir.is_dir():
|
||||||
data_dir.mkdir()
|
data_dir.mkdir()
|
||||||
db_path = settings.BASE_DIR / "db.sqlite3"
|
db_path = settings.BASE_DIR / "db.sqlite3"
|
||||||
if db_path.exists() or connection.vendor != "sqlite":
|
if db_path.exists():
|
||||||
call_command("flush", "--noinput")
|
call_command("flush", "--noinput")
|
||||||
self.stdout.write("Existing database reset")
|
self.stdout.write("Existing database reset")
|
||||||
call_command("migrate")
|
call_command("migrate")
|
||||||
|
@ -563,21 +563,14 @@ class Migration(migrations.Migration):
|
|||||||
fields=[],
|
fields=[],
|
||||||
options={"proxy": True},
|
options={"proxy": True},
|
||||||
bases=("core.group",),
|
bases=("core.group",),
|
||||||
managers=[("objects", django.contrib.auth.models.GroupManager())],
|
managers=[("objects", core.models.MetaGroupManager())],
|
||||||
),
|
),
|
||||||
# at first, there existed a RealGroupManager and a RealGroupManager,
|
|
||||||
# which have been since been removed.
|
|
||||||
# However, this removal broke the migrations because it caused an ImportError.
|
|
||||||
# Thus, the managers MetaGroupManager (above) and RealGroupManager (below)
|
|
||||||
# have been replaced by the base django GroupManager to fix the import.
|
|
||||||
# As those managers aren't actually used in migrations,
|
|
||||||
# this replacement doesn't break anything.
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="RealGroup",
|
name="RealGroup",
|
||||||
fields=[],
|
fields=[],
|
||||||
options={"proxy": True},
|
options={"proxy": True},
|
||||||
bases=("core.group",),
|
bases=("core.group",),
|
||||||
managers=[("objects", django.contrib.auth.models.GroupManager())],
|
managers=[("objects", core.models.RealGroupManager())],
|
||||||
),
|
),
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name="page", unique_together={("name", "parent")}
|
name="page", unique_together={("name", "parent")}
|
||||||
|
@ -1,82 +0,0 @@
|
|||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,37 +0,0 @@
|
|||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,164 +0,0 @@
|
|||||||
# 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
|
|
||||||
),
|
|
||||||
]
|
|
316
core/models.py
316
core/models.py
@ -30,19 +30,26 @@ import string
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional, Self
|
from typing import TYPE_CHECKING, Any, Optional, Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser, UserManager
|
from django.contrib.auth.models import AbstractBaseUser, UserManager
|
||||||
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
|
from django.contrib.auth.models import (
|
||||||
from django.contrib.auth.models import Group as AuthGroup
|
AnonymousUser as AuthAnonymousUser,
|
||||||
|
)
|
||||||
|
from django.contrib.auth.models import (
|
||||||
|
Group as AuthGroup,
|
||||||
|
)
|
||||||
|
from django.contrib.auth.models import (
|
||||||
|
GroupManager as AuthGroupManager,
|
||||||
|
)
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
from django.core import validators
|
from django.core import validators
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Exists, F, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@ -57,15 +64,33 @@ if TYPE_CHECKING:
|
|||||||
from club.models import Club
|
from club.models import Club
|
||||||
|
|
||||||
|
|
||||||
class Group(AuthGroup):
|
class RealGroupManager(AuthGroupManager):
|
||||||
"""Wrapper around django.auth.Group"""
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(is_meta=False)
|
||||||
|
|
||||||
is_manually_manageable = models.BooleanField(
|
|
||||||
_("Is manually manageable"),
|
class MetaGroupManager(AuthGroupManager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(is_meta=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Group(AuthGroup):
|
||||||
|
"""Implement both RealGroups and Meta groups.
|
||||||
|
|
||||||
|
Groups are sorted by their is_meta property
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: If False, this is a RealGroup
|
||||||
|
is_meta = models.BooleanField(
|
||||||
|
_("meta group status"),
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_("If False, this shouldn't be shown on group management pages"),
|
help_text=_("Whether a group is a meta group or not"),
|
||||||
)
|
)
|
||||||
description = models.TextField(_("description"))
|
#: Description of the group
|
||||||
|
description = models.CharField(_("description"), max_length=60)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
return reverse("core:group_list")
|
return reverse("core:group_list")
|
||||||
@ -81,6 +106,65 @@ class Group(AuthGroup):
|
|||||||
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
|
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
|
||||||
|
|
||||||
|
|
||||||
|
class MetaGroup(Group):
|
||||||
|
"""MetaGroups are dynamically created groups.
|
||||||
|
|
||||||
|
Generally used with clubs where creating a club creates two groups:
|
||||||
|
|
||||||
|
* club-SITH_BOARD_SUFFIX
|
||||||
|
* club-SITH_MEMBER_SUFFIX
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=False
|
||||||
|
objects = MetaGroupManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
proxy = True
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.is_meta = True
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def associated_club(self) -> Club | None:
|
||||||
|
"""Return the group associated with this meta group.
|
||||||
|
|
||||||
|
The result of this function is cached
|
||||||
|
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The associated club if it exists, else None
|
||||||
|
"""
|
||||||
|
from club.models import Club
|
||||||
|
|
||||||
|
if self.name.endswith(settings.SITH_BOARD_SUFFIX):
|
||||||
|
# replace this with str.removesuffix as soon as Python
|
||||||
|
# is upgraded to 3.10
|
||||||
|
club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)]
|
||||||
|
elif self.name.endswith(settings.SITH_MEMBER_SUFFIX):
|
||||||
|
club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
club = cache.get(f"sith_club_{club_name}")
|
||||||
|
if club is None:
|
||||||
|
club = Club.objects.filter(unix_name=club_name).first()
|
||||||
|
cache.set(f"sith_club_{club_name}", club)
|
||||||
|
return club
|
||||||
|
|
||||||
|
|
||||||
|
class RealGroup(Group):
|
||||||
|
"""RealGroups are created by the developer.
|
||||||
|
|
||||||
|
Most of the time they match a number in settings to be easily used for permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=True
|
||||||
|
objects = RealGroupManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
proxy = True
|
||||||
|
|
||||||
|
|
||||||
def validate_promo(value: int) -> None:
|
def validate_promo(value: int) -> None:
|
||||||
start_year = settings.SITH_SCHOOL_START_YEAR
|
start_year = settings.SITH_SCHOOL_START_YEAR
|
||||||
delta = (localdate() + timedelta(days=180)).year - start_year
|
delta = (localdate() + timedelta(days=180)).year - start_year
|
||||||
@ -126,35 +210,13 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
|
|||||||
else:
|
else:
|
||||||
group = Group.objects.filter(name=name).first()
|
group = Group.objects.filter(name=name).first()
|
||||||
if group is not None:
|
if group is not None:
|
||||||
name = group.name.replace(" ", "_")
|
cache.set(f"sith_group_{group.id}", group)
|
||||||
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
|
cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
|
||||||
else:
|
else:
|
||||||
cache.set(f"sith_group_{pk_or_name}", "not_found")
|
cache.set(f"sith_group_{pk_or_name}", "not_found")
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
class BanGroup(AuthGroup):
|
|
||||||
"""An anti-group, that removes permissions instead of giving them.
|
|
||||||
|
|
||||||
Users are linked to BanGroups through UserBan objects.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
user = User.objects.get(username="...")
|
|
||||||
ban_group = BanGroup.objects.first()
|
|
||||||
UserBan.objects.create(user=user, ban_group=ban_group, reason="...")
|
|
||||||
|
|
||||||
assert user.ban_groups.contains(ban_group)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
description = models.TextField(_("description"))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("ban group")
|
|
||||||
verbose_name_plural = _("ban groups")
|
|
||||||
|
|
||||||
|
|
||||||
class UserQuerySet(models.QuerySet):
|
class UserQuerySet(models.QuerySet):
|
||||||
def filter_inactive(self) -> Self:
|
def filter_inactive(self) -> Self:
|
||||||
from counter.models import Refilling, Selling
|
from counter.models import Refilling, Selling
|
||||||
@ -180,7 +242,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractBaseUser):
|
||||||
"""Defines the base user class, useable in every app.
|
"""Defines the base user class, useable in every app.
|
||||||
|
|
||||||
This is almost the same as the auth module AbstractUser since it inherits from it,
|
This is almost the same as the auth module AbstractUser since it inherits from it,
|
||||||
@ -191,28 +253,51 @@ class User(AbstractUser):
|
|||||||
Required fields: email, first_name, last_name, date_of_birth
|
Required fields: email, first_name, last_name, date_of_birth
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
username = models.CharField(
|
||||||
|
_("username"),
|
||||||
|
max_length=254,
|
||||||
|
unique=True,
|
||||||
|
help_text=_(
|
||||||
|
"Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
|
||||||
|
),
|
||||||
|
validators=[
|
||||||
|
validators.RegexValidator(
|
||||||
|
r"^[\w.+-]+$",
|
||||||
|
_(
|
||||||
|
"Enter a valid username. This value may contain only "
|
||||||
|
"letters, numbers "
|
||||||
|
"and ./+/-/_ characters."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
error_messages={"unique": _("A user with that username already exists.")},
|
||||||
|
)
|
||||||
first_name = models.CharField(_("first name"), max_length=64)
|
first_name = models.CharField(_("first name"), max_length=64)
|
||||||
last_name = models.CharField(_("last name"), max_length=64)
|
last_name = models.CharField(_("last name"), max_length=64)
|
||||||
email = models.EmailField(_("email address"), unique=True)
|
email = models.EmailField(_("email address"), unique=True)
|
||||||
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
|
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
|
||||||
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
|
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
|
||||||
last_update = models.DateTimeField(_("last update"), auto_now=True)
|
is_staff = models.BooleanField(
|
||||||
groups = models.ManyToManyField(
|
_("staff status"),
|
||||||
Group,
|
default=False,
|
||||||
verbose_name=_("groups"),
|
help_text=_("Designates whether the user can log into this admin site."),
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
_("active"),
|
||||||
|
default=True,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"The groups this user belongs to. A user will get all permissions "
|
"Designates whether this user should be treated as active. "
|
||||||
"granted to each of their groups."
|
"Unselect this instead of deleting accounts."
|
||||||
),
|
),
|
||||||
related_name="users",
|
|
||||||
)
|
)
|
||||||
ban_groups = models.ManyToManyField(
|
date_joined = models.DateField(_("date joined"), auto_now_add=True)
|
||||||
BanGroup,
|
last_update = models.DateTimeField(_("last update"), auto_now=True)
|
||||||
verbose_name=_("ban groups"),
|
is_superuser = models.BooleanField(
|
||||||
through="UserBan",
|
_("superuser"),
|
||||||
help_text=_("The bans this user has received."),
|
default=False,
|
||||||
related_name="users",
|
help_text=_("Designates whether this user is a superuser. "),
|
||||||
)
|
)
|
||||||
|
groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
|
||||||
home = models.OneToOneField(
|
home = models.OneToOneField(
|
||||||
"SithFile",
|
"SithFile",
|
||||||
related_name="home_of",
|
related_name="home_of",
|
||||||
@ -316,6 +401,8 @@ class User(AbstractUser):
|
|||||||
|
|
||||||
objects = CustomUserManager()
|
objects = CustomUserManager()
|
||||||
|
|
||||||
|
USERNAME_FIELD = "username"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.get_display_name()
|
return self.get_display_name()
|
||||||
|
|
||||||
@ -335,23 +422,22 @@ class User(AbstractUser):
|
|||||||
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
|
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
|
def has_module_perms(self, package_name: str) -> bool:
|
||||||
|
return self.is_active
|
||||||
|
|
||||||
|
def has_perm(self, perm: str, obj: Any = None) -> bool:
|
||||||
|
return self.is_active and self.is_superuser
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def was_subscribed(self) -> bool:
|
def was_subscribed(self) -> bool:
|
||||||
if "is_subscribed" in self.__dict__ and self.is_subscribed:
|
|
||||||
# if the user is currently subscribed, he is an old subscriber too
|
|
||||||
# if the property has already been cached, avoid another request
|
|
||||||
return True
|
|
||||||
return self.subscriptions.exists()
|
return self.subscriptions.exists()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_subscribed(self) -> bool:
|
def is_subscribed(self) -> bool:
|
||||||
if "was_subscribed" in self.__dict__ and not self.was_subscribed:
|
s = self.subscriptions.filter(
|
||||||
# if the user never subscribed, he cannot be a subscriber now.
|
|
||||||
# if the property has already been cached, avoid another request
|
|
||||||
return False
|
|
||||||
return self.subscriptions.filter(
|
|
||||||
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
|
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
|
||||||
).exists()
|
)
|
||||||
|
return s.exists()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def account_balance(self):
|
def account_balance(self):
|
||||||
@ -388,6 +474,18 @@ class User(AbstractUser):
|
|||||||
return self.was_subscribed
|
return self.was_subscribed
|
||||||
if group.id == settings.SITH_GROUP_ROOT_ID:
|
if group.id == settings.SITH_GROUP_ROOT_ID:
|
||||||
return self.is_root
|
return self.is_root
|
||||||
|
if group.is_meta:
|
||||||
|
# check if this group is associated with a club
|
||||||
|
group.__class__ = MetaGroup
|
||||||
|
club = group.associated_club
|
||||||
|
if club is None:
|
||||||
|
return False
|
||||||
|
membership = club.get_membership_for(self)
|
||||||
|
if membership is None:
|
||||||
|
return False
|
||||||
|
if group.name.endswith(settings.SITH_MEMBER_SUFFIX):
|
||||||
|
return True
|
||||||
|
return membership.role > settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
return group in self.cached_groups
|
return group in self.cached_groups
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -412,11 +510,12 @@ class User(AbstractUser):
|
|||||||
return any(g.id == root_id for g in self.cached_groups)
|
return any(g.id == root_id for g in self.cached_groups)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_board_member(self) -> bool:
|
def is_board_member(self):
|
||||||
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
|
main_club = settings.SITH_MAIN_CLUB["unix_name"]
|
||||||
|
return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def can_read_subscription_history(self) -> bool:
|
def can_read_subscription_history(self):
|
||||||
if self.is_root or self.is_board_member:
|
if self.is_root or self.is_board_member:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -430,13 +529,13 @@ class User(AbstractUser):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def can_create_subscription(self) -> bool:
|
def can_create_subscription(self):
|
||||||
return self.is_root or (
|
from club.models import Club
|
||||||
self.memberships.board()
|
|
||||||
.ongoing()
|
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
|
||||||
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
|
if club in self.clubs_with_rights:
|
||||||
.exists()
|
return True
|
||||||
)
|
return False
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_launderette_manager(self):
|
def is_launderette_manager(self):
|
||||||
@ -451,12 +550,12 @@ class User(AbstractUser):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_banned_alcohol(self) -> bool:
|
def is_banned_alcohol(self):
|
||||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
|
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_banned_counter(self) -> bool:
|
def is_banned_counter(self):
|
||||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
|
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def age(self) -> int:
|
def age(self) -> int:
|
||||||
@ -500,6 +599,11 @@ class User(AbstractUser):
|
|||||||
"date_of_birth": self.date_of_birth,
|
"date_of_birth": self.date_of_birth,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_full_name(self):
|
||||||
|
"""Returns the first_name plus the last_name, with a space in between."""
|
||||||
|
full_name = "%s %s" % (self.first_name, self.last_name)
|
||||||
|
return full_name.strip()
|
||||||
|
|
||||||
def get_short_name(self):
|
def get_short_name(self):
|
||||||
"""Returns the short name for the user."""
|
"""Returns the short name for the user."""
|
||||||
if self.nick_name:
|
if self.nick_name:
|
||||||
@ -515,6 +619,14 @@ class User(AbstractUser):
|
|||||||
return "%s (%s)" % (self.get_full_name(), self.nick_name)
|
return "%s (%s)" % (self.get_full_name(), self.nick_name)
|
||||||
return self.get_full_name()
|
return self.get_full_name()
|
||||||
|
|
||||||
|
def get_age(self):
|
||||||
|
"""Returns the age."""
|
||||||
|
today = timezone.now()
|
||||||
|
born = self.date_of_birth
|
||||||
|
return (
|
||||||
|
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||||
|
)
|
||||||
|
|
||||||
def get_family(
|
def get_family(
|
||||||
self,
|
self,
|
||||||
godfathers_depth: NonNegativeInt = 4,
|
godfathers_depth: NonNegativeInt = 4,
|
||||||
@ -758,52 +870,6 @@ class AnonymousUser(AuthAnonymousUser):
|
|||||||
return _("Visitor")
|
return _("Visitor")
|
||||||
|
|
||||||
|
|
||||||
class UserBan(models.Model):
|
|
||||||
"""A ban of a user.
|
|
||||||
|
|
||||||
A user can be banned for a specific reason, for a specific duration.
|
|
||||||
The expiration date is indicative, and the ban should be removed manually.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ban_group = models.ForeignKey(
|
|
||||||
BanGroup,
|
|
||||||
verbose_name=_("ban type"),
|
|
||||||
related_name="user_bans",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User, verbose_name=_("user"), related_name="bans", on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
|
||||||
expires_at = models.DateTimeField(
|
|
||||||
_("expires at"),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text=_(
|
|
||||||
"When the ban should be removed. "
|
|
||||||
"Currently, there is no automatic removal, so this is purely indicative. "
|
|
||||||
"Automatic ban removal may be implemented later on."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
reason = models.TextField(_("reason"))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("user ban")
|
|
||||||
verbose_name_plural = _("user bans")
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["ban_group", "user"], name="unique_ban_type_per_user"
|
|
||||||
),
|
|
||||||
models.CheckConstraint(
|
|
||||||
check=Q(expires_at__gte=F("created_at")),
|
|
||||||
name="user_ban_end_after_start",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Ban of user {self.user.id}"
|
|
||||||
|
|
||||||
|
|
||||||
class Preferences(models.Model):
|
class Preferences(models.Model):
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
User, related_name="_preferences", on_delete=models.CASCADE
|
User, related_name="_preferences", on_delete=models.CASCADE
|
||||||
@ -916,17 +982,19 @@ class SithFile(models.Model):
|
|||||||
if copy_rights:
|
if copy_rights:
|
||||||
self.copy_rights()
|
self.copy_rights()
|
||||||
if self.is_in_sas:
|
if self.is_in_sas:
|
||||||
for user in User.objects.filter(
|
for u in (
|
||||||
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
|
RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
|
||||||
|
.first()
|
||||||
|
.users.all()
|
||||||
):
|
):
|
||||||
Notification(
|
Notification(
|
||||||
user=user,
|
user=u,
|
||||||
url=reverse("sas:moderation"),
|
url=reverse("sas:moderation"),
|
||||||
type="SAS_MODERATION",
|
type="SAS_MODERATION",
|
||||||
param="1",
|
param="1",
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
def is_owned_by(self, user: User) -> bool:
|
def is_owned_by(self, user):
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
if user.is_root:
|
if user.is_root:
|
||||||
@ -941,7 +1009,7 @@ class SithFile(models.Model):
|
|||||||
return True
|
return True
|
||||||
return user.id == self.owner_id
|
return user.id == self.owner_id
|
||||||
|
|
||||||
def can_be_viewed_by(self, user: User) -> bool:
|
def can_be_viewed_by(self, user):
|
||||||
if hasattr(self, "profile_of"):
|
if hasattr(self, "profile_of"):
|
||||||
return user.can_view(self.profile_of)
|
return user.can_view(self.profile_of)
|
||||||
if hasattr(self, "avatar_of"):
|
if hasattr(self, "avatar_of"):
|
||||||
|
@ -4,7 +4,6 @@ from typing import Annotated
|
|||||||
from annotated_types import MinLen
|
from annotated_types import MinLen
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from haystack.query import SearchQuerySet
|
from haystack.query import SearchQuerySet
|
||||||
from ninja import FilterSchema, ModelSchema, Schema
|
from ninja import FilterSchema, ModelSchema, Schema
|
||||||
@ -38,13 +37,13 @@ class UserProfileSchema(ModelSchema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_profile_url(obj: User) -> str:
|
def resolve_profile_url(obj: User) -> str:
|
||||||
return reverse("core:user_profile", kwargs={"user_id": obj.pk})
|
return obj.get_absolute_url()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_profile_pict(obj: User) -> str:
|
def resolve_profile_pict(obj: User) -> str:
|
||||||
if obj.profile_pict_id is None:
|
if obj.profile_pict_id is None:
|
||||||
return staticfiles_storage.url("core/img/unknown.jpg")
|
return staticfiles_storage.url("core/img/unknown.jpg")
|
||||||
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
|
return obj.profile_pict.get_download_url()
|
||||||
|
|
||||||
|
|
||||||
class SithFileSchema(ModelSchema):
|
class SithFileSchema(ModelSchema):
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import sort from "@alpinejs/sort";
|
|
||||||
import Alpine from "alpinejs";
|
import Alpine from "alpinejs";
|
||||||
|
|
||||||
Alpine.plugin(sort);
|
|
||||||
window.Alpine = Alpine;
|
window.Alpine = Alpine;
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
@ -67,8 +67,6 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
remove_button: {
|
remove_button: {
|
||||||
title: gettext("Remove"),
|
title: gettext("Remove"),
|
||||||
},
|
},
|
||||||
// biome-ignore lint/style/useNamingConvention: this is required by the api
|
|
||||||
restore_on_backspace: {},
|
|
||||||
},
|
},
|
||||||
persist: false,
|
persist: false,
|
||||||
maxItems: this.node.multiple ? this.max : 1,
|
maxItems: this.node.multiple ? this.max : 1,
|
||||||
@ -105,12 +103,6 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
||||||
protected filter?: (items: TomOption[]) => TomOption[] = null;
|
protected filter?: (items: TomOption[]) => TomOption[] = null;
|
||||||
protected minCharNumberForSearch = 2;
|
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 valueField: string;
|
||||||
protected abstract labelField: string;
|
protected abstract labelField: string;
|
||||||
@ -143,13 +135,7 @@ export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
|||||||
this.widget.clearOptions();
|
this.widget.clearOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check in the cache if this query has already been typed
|
const resp = await this.search(query);
|
||||||
// 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) {
|
if (this.filter) {
|
||||||
callback(this.filter(resp), []);
|
callback(this.filter(resp), []);
|
||||||
|
@ -1,73 +0,0 @@
|
|||||||
import clip from "@arendjr/text-clipper";
|
|
||||||
|
|
||||||
/*
|
|
||||||
This script adds a way to have a 'show more / show less' button
|
|
||||||
on some text content.
|
|
||||||
|
|
||||||
The usage is very simple, you just have to add the attribute `show-more`
|
|
||||||
with the desired max size to the element you want to add the button to.
|
|
||||||
This script does html matching and is able to properly cut rendered markdown.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
<p show-more="20">
|
|
||||||
My very long text will be cut by this script
|
|
||||||
</p>
|
|
||||||
*/
|
|
||||||
|
|
||||||
function showMore(element: HTMLElement) {
|
|
||||||
if (!element.hasAttribute("show-more")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark element as loaded so we can hide unloaded
|
|
||||||
// tags with css and avoid blinking text
|
|
||||||
element.setAttribute("show-more-loaded", "");
|
|
||||||
|
|
||||||
const fullContent = element.innerHTML;
|
|
||||||
const clippedContent = clip(
|
|
||||||
element.innerHTML,
|
|
||||||
Number.parseInt(element.getAttribute("show-more") as string),
|
|
||||||
{
|
|
||||||
html: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// If already at the desired size, we don't do anything
|
|
||||||
if (clippedContent === fullContent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionLink = document.createElement("a");
|
|
||||||
actionLink.setAttribute("class", "show-more-link");
|
|
||||||
|
|
||||||
let opened = false;
|
|
||||||
|
|
||||||
const setText = () => {
|
|
||||||
if (opened) {
|
|
||||||
element.innerHTML = fullContent;
|
|
||||||
actionLink.innerText = gettext("Show less");
|
|
||||||
} else {
|
|
||||||
element.innerHTML = clippedContent;
|
|
||||||
actionLink.innerText = gettext("Show more");
|
|
||||||
}
|
|
||||||
element.appendChild(document.createElement("br"));
|
|
||||||
element.appendChild(actionLink);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
opened = !opened;
|
|
||||||
setText();
|
|
||||||
};
|
|
||||||
|
|
||||||
setText();
|
|
||||||
actionLink.addEventListener("click", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
toggle();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
for (const elem of document.querySelectorAll("[show-more]")) {
|
|
||||||
showMore(elem as HTMLElement);
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,11 +1,3 @@
|
|||||||
import htmx from "htmx.org";
|
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 });
|
Object.assign(window, { htmx });
|
||||||
|
@ -22,13 +22,10 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
|
|||||||
|
|
||||||
// TODO : If one day a test workflow is made for JS in this project
|
// TODO : If one day a test workflow is made for JS in this project
|
||||||
// please test this function. A all cost.
|
// please test this function. A all cost.
|
||||||
/**
|
|
||||||
* Load complete dataset from paginated routes.
|
|
||||||
*/
|
|
||||||
export const paginated = async <T>(
|
export const paginated = async <T>(
|
||||||
endpoint: PaginatedEndpoint<T>,
|
endpoint: PaginatedEndpoint<T>,
|
||||||
options?: PaginatedRequest,
|
options?: PaginatedRequest,
|
||||||
): Promise<T[]> => {
|
) => {
|
||||||
const maxPerPage = 199;
|
const maxPerPage = 199;
|
||||||
const queryParams = options ?? {};
|
const queryParams = options ?? {};
|
||||||
queryParams.query = queryParams.query ?? {};
|
queryParams.query = queryParams.query ?? {};
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
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
37
core/static/bundled/utils/types.d.ts
vendored
@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,16 +6,7 @@
|
|||||||
**/
|
**/
|
||||||
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
|
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
|
||||||
return (component: CustomElementConstructor) => {
|
return (component: CustomElementConstructor) => {
|
||||||
try {
|
window.customElements.define(name, component, options);
|
||||||
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,17 +24,9 @@ $black-color: hsl(0, 0%, 17%);
|
|||||||
|
|
||||||
$faceblue: hsl(221, 44%, 41%);
|
$faceblue: hsl(221, 44%, 41%);
|
||||||
$twitblue: hsl(206, 82%, 63%);
|
$twitblue: hsl(206, 82%, 63%);
|
||||||
$discordblurple: #7289da;
|
|
||||||
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
|
|
||||||
$githubblack: rgb(22, 22, 20);
|
|
||||||
|
|
||||||
$shadow-color: rgb(223, 223, 223);
|
$shadow-color: rgb(223, 223, 223);
|
||||||
|
|
||||||
$background-button-color: hsl(0, 0%, 95%);
|
$background-button-color: hsl(0, 0%, 95%);
|
||||||
|
|
||||||
$deepblue: #354a5f;
|
$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;
|
|
||||||
}
|
|
@ -1,27 +1,11 @@
|
|||||||
.ts-wrapper.multi .ts-control {
|
|
||||||
min-width: calc(100% - 0.2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* This also requires ajax-select-index.css */
|
/* This also requires ajax-select-index.css */
|
||||||
.ts-dropdown {
|
.ts-dropdown {
|
||||||
width: calc(100% - 0.2rem);
|
|
||||||
left: 0.1rem;
|
|
||||||
top: calc(100% - 0.2rem - var(--nf-input-border-bottom-width));
|
|
||||||
border: var(--nf-input-border-color) var(--nf-input-border-width) solid;
|
|
||||||
border-top: none;
|
|
||||||
border-bottom-width: var(--nf-input-border-bottom-width);
|
|
||||||
|
|
||||||
.option.active {
|
|
||||||
background-color: #e5eafa;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-item {
|
.select-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@ -32,44 +16,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ts-wrapper {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.ts-wrapper.single {
|
.ts-wrapper.single {
|
||||||
> .ts-control {
|
width: 263px; // same length as regular text inputs
|
||||||
box-shadow: none;
|
|
||||||
max-width: 300px;
|
|
||||||
background-color: var(--nf-input-background-color);
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> .ts-dropdown {
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-wrapper input[type="text"] {
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-wrapper.multi, .ts-wrapper.single {
|
|
||||||
.ts-control:has(input:focus) {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nf-input-focus-border-color);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
|
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
|
||||||
border-left: 1px solid #aaa;
|
border-left: 1px solid #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-wrapper.multi.has-items .ts-control {
|
.ts-wrapper.multi .ts-control {
|
||||||
padding: calc(var(--nf-input-size) * 0.65);
|
|
||||||
display: flex;
|
|
||||||
gap: calc(var(--nf-input-size) / 3);
|
|
||||||
|
|
||||||
[data-value],
|
[data-value],
|
||||||
[data-value].active {
|
[data-value].active {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
@ -78,17 +37,19 @@
|
|||||||
border: 1px solid #aaa;
|
border: 1px solid #aaa;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
||||||
.remove {
|
|
||||||
vertical-align: baseline;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-wrapper.focus .ts-control {
|
.ts-dropdown {
|
||||||
box-shadow: none;
|
.option.active {
|
||||||
}
|
background-color: #e5eafa;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
@ -1,96 +0,0 @@
|
|||||||
@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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
|||||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
|
||||||
|
|
||||||
$small-devices: 576px;
|
|
||||||
$medium-devices: 768px;
|
|
||||||
$large-devices: 992px;
|
|
@ -1,730 +0,0 @@
|
|||||||
@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"],
|
|
||||||
.ts-control {
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 0.4em;
|
|
||||||
margin: 0.1em;
|
|
||||||
font-size: 1.2em;
|
|
||||||
border-radius: 5px;
|
|
||||||
max-width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 7px;
|
|
||||||
font-size: 1.2em;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
select, .ts-control {
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1.2em;
|
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:not(.button) {
|
|
||||||
text-decoration: none;
|
|
||||||
color: $primary-dark-color;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $primary-light-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
color: $primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:not(.ts-control) > {
|
|
||||||
input[type="text"],
|
|
||||||
input[type="email"],
|
|
||||||
input[type="tel"],
|
|
||||||
input[type="url"],
|
|
||||||
input[type="password"],
|
|
||||||
input[type="number"],
|
|
||||||
input[type="date"],
|
|
||||||
input[type="week"],
|
|
||||||
input[type="time"],
|
|
||||||
input[type="search"],
|
|
||||||
textarea,
|
|
||||||
input[type="month"],
|
|
||||||
select {
|
|
||||||
min-width: 300px;
|
|
||||||
|
|
||||||
&.grow {
|
|
||||||
width: 95%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
input[type="checkbox"],
|
|
||||||
input[type="radio"],
|
|
||||||
input[type="email"],
|
|
||||||
input[type="tel"],
|
|
||||||
input[type="url"],
|
|
||||||
input[type="password"],
|
|
||||||
input[type="number"],
|
|
||||||
input[type="date"],
|
|
||||||
input[type="datetime-local"],
|
|
||||||
input[type="week"],
|
|
||||||
input[type="time"],
|
|
||||||
input[type="month"],
|
|
||||||
input[type="search"],
|
|
||||||
textarea,
|
|
||||||
select,
|
|
||||||
.ts-control {
|
|
||||||
background: var(--nf-input-background-color);
|
|
||||||
font-size: var(--nf-input-font-size);
|
|
||||||
border-color: var(--nf-input-border-color);
|
|
||||||
border-width: var(--nf-input-border-width);
|
|
||||||
border-style: var(--nf-input-border-style);
|
|
||||||
box-shadow: none;
|
|
||||||
border-radius: var(--nf-input-border-radius);
|
|
||||||
border-bottom-width: var(--nf-input-border-bottom-width);
|
|
||||||
color: var(--nf-input-color);
|
|
||||||
max-width: 95%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: calc(var(--nf-input-size) * 0.65);
|
|
||||||
line-height: normal;
|
|
||||||
appearance: none;
|
|
||||||
transition: all 0.15s ease-out;
|
|
||||||
|
|
||||||
// ------------- VALID/INVALID
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
&:not(:placeholder-shown):invalid {
|
|
||||||
background-color: var(--nf-invalid-input-background-color);
|
|
||||||
border-color: var(--nf-valid-input-border-color);
|
|
||||||
border-bottom-color: var(--nf-invalid-input-border-bottom-color);
|
|
||||||
color: var(--nf-invalid-input-color);
|
|
||||||
|
|
||||||
// Reset to default when focus
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
background-color: var(--nf-input-background-color);
|
|
||||||
border-color: var(--nf-input-border-color);
|
|
||||||
color: var(--nf-input-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:placeholder-shown):valid {
|
|
||||||
background-color: var(--nf-valid-input-background-color);
|
|
||||||
border-color: var(--nf-valid-input-border-color);
|
|
||||||
border-bottom-color: var(--nf-valid-input-border-bottom-color);
|
|
||||||
color: var(--nf-valid-input-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------- DISABLED
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- PLACEHOLDERS
|
|
||||||
|
|
||||||
&::-webkit-input-placeholder {
|
|
||||||
color: var(--nf-input-placeholder-color);
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:-ms-input-placeholder {
|
|
||||||
color: var(--nf-input-placeholder-color);
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-placeholder {
|
|
||||||
color: var(--nf-input-placeholder-color);
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:-moz-placeholder {
|
|
||||||
color: var(--nf-input-placeholder-color);
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- FOCUS
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nf-input-focus-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- ADDITIONAL TEXT BENEATH INPUT FIELDS
|
|
||||||
|
|
||||||
+ small {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- ICONS
|
|
||||||
|
|
||||||
--icon-padding: calc(var(--nf-input-size) * 2.25);
|
|
||||||
--icon-background-offset: calc(var(--nf-input-size) * 0.75);
|
|
||||||
|
|
||||||
&.icon-left {
|
|
||||||
background-position: left var(--icon-background-offset) bottom 50%;
|
|
||||||
padding-left: var(--icon-padding);
|
|
||||||
background-size: var(--nf-input-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.icon-right {
|
|
||||||
background-position: right var(--icon-background-offset) bottom 50%;
|
|
||||||
padding-right: var(--icon-padding);
|
|
||||||
background-size: var(--nf-input-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When a field has a icon and is autofilled, the background image is removed
|
|
||||||
// by the browser. To negate this we reset the padding, not great but okay
|
|
||||||
|
|
||||||
&:-webkit-autofill {
|
|
||||||
padding: calc(var(--nf-input-size) * 0.75) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- SEARCH
|
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
&:placeholder-shown {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-search'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
|
|
||||||
background-position: left calc(var(--nf-input-size) * 0.75) bottom 50%;
|
|
||||||
padding-left: calc(var(--nf-input-size) * 2.25);
|
|
||||||
background-size: var(--nf-input-size);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-search-cancel-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: var(--nf-input-size);
|
|
||||||
height: var(--nf-input-size);
|
|
||||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
padding-left: calc(var(--nf-input-size) * 0.75);
|
|
||||||
background-position: left calc(var(--nf-input-size) * -1) bottom 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- EMAIL
|
|
||||||
|
|
||||||
input[type="email"][class^="icon"] {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-at-sign'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- TEL
|
|
||||||
|
|
||||||
input[type="tel"][class^="icon"] {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-phone'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- URL
|
|
||||||
|
|
||||||
input[type="url"][class^="icon"] {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-link'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- PASSWORD
|
|
||||||
|
|
||||||
input[type="password"] {
|
|
||||||
letter-spacing: 2px;
|
|
||||||
|
|
||||||
&[class^="icon"] {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-lock'%3E%3Crect x='3' y='11' width='18' height='11' rx='2' ry='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- RANGE
|
|
||||||
|
|
||||||
input[type="range"] {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: for some reason grouping these doesn't work (just like :placeholder)
|
|
||||||
|
|
||||||
@mixin track {
|
|
||||||
width: 100%;
|
|
||||||
height: var(--nf-slider-track-height);
|
|
||||||
background: var(--nf-slider-track-background);
|
|
||||||
border-radius: var(--nf-slider-track-border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin thumb {
|
|
||||||
height: var(--nf-slider-thumb-size);
|
|
||||||
width: var(--nf-slider-thumb-size);
|
|
||||||
border-radius: var(--nf-slider-thumb-size);
|
|
||||||
background: var(--nf-slider-thumb-background);
|
|
||||||
border: 0;
|
|
||||||
border: var(--nf-slider-thumb-border-width) solid var(--nf-slider-thumb-border-color);
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin thumb-focus {
|
|
||||||
box-shadow: 0 0 0 var(--nf-slider-thumb-border-focus-width) var(--nf-slider-thumb-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-slider-runnable-track {
|
|
||||||
@include track;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-range-track {
|
|
||||||
@include track;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-slider-thumb {
|
|
||||||
@include thumb;
|
|
||||||
margin-top: calc(
|
|
||||||
(
|
|
||||||
calc(var(--nf-slider-track-height) - var(--nf-slider-thumb-size)) *
|
|
||||||
0.5
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-range-thumb {
|
|
||||||
@include thumb;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus::-webkit-slider-thumb {
|
|
||||||
@include thumb-focus;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus::-moz-range-thumb {
|
|
||||||
@include thumb-focus;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- COLOR
|
|
||||||
|
|
||||||
input[type="color"] {
|
|
||||||
border: var(--nf-input-border-width) solid var(--nf-input-border-color);
|
|
||||||
border-bottom-width: var(--nf-input-border-bottom-width);
|
|
||||||
height: calc(var(--nf-input-size) * 2);
|
|
||||||
border-radius: var(--nf-input-border-radius);
|
|
||||||
padding: calc(var(--nf-input-border-width) * 2);
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nf-input-focus-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-color-swatch-wrapper {
|
|
||||||
padding: 5%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin swatch {
|
|
||||||
border-radius: calc(var(--nf-input-border-radius) / 2);
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-color-swatch {
|
|
||||||
@include swatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-color-swatch {
|
|
||||||
@include swatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- NUMBER
|
|
||||||
|
|
||||||
input[type="number"] {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- DATES
|
|
||||||
|
|
||||||
input[type="date"],
|
|
||||||
input[type="datetime-local"],
|
|
||||||
input[type="week"],
|
|
||||||
input[type="month"] {
|
|
||||||
min-width: 300px;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-calendar'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="time"] {
|
|
||||||
min-width: 6em;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-clock'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="date"],
|
|
||||||
input[type="datetime-local"],
|
|
||||||
input[type="week"],
|
|
||||||
input[type="time"],
|
|
||||||
input[type="month"] {
|
|
||||||
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: var(--nf-input-size);
|
|
||||||
|
|
||||||
&::-webkit-inner-spin-button,
|
|
||||||
&::-webkit-calendar-picker-indicator {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// FireFox reset
|
|
||||||
// FF has restricted control of styling the date/time inputs.
|
|
||||||
// That's why we don't show icons for FF users, and leave basic styling in place.
|
|
||||||
@-moz-document url-prefix() {
|
|
||||||
min-width: auto;
|
|
||||||
width: auto;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- TEXAREA
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- CHECKBOX/RADIO
|
|
||||||
|
|
||||||
input[type="checkbox"],
|
|
||||||
input[type="radio"] {
|
|
||||||
width: var(--nf-input-size);
|
|
||||||
height: var(--nf-input-size);
|
|
||||||
padding: inherit;
|
|
||||||
margin: 0;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
border-radius: calc(var(--nf-input-border-radius) / 2);
|
|
||||||
border-width: var(--nf-input-border-width);
|
|
||||||
cursor: pointer;
|
|
||||||
background-position: center center;
|
|
||||||
|
|
||||||
&:focus:not(:checked) {
|
|
||||||
border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
+ label {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-left: calc(var(--nf-input-size) / 2.5);
|
|
||||||
font-weight: normal;
|
|
||||||
user-select: none;
|
|
||||||
cursor: pointer;
|
|
||||||
max-width: calc(100% - calc(var(--nf-input-size) * 2));
|
|
||||||
line-height: normal;
|
|
||||||
|
|
||||||
> small {
|
|
||||||
margin-top: calc(var(--nf-input-size) / 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
&:checked {
|
|
||||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23FFFFFF' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-check'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") no-repeat center center/85%;
|
|
||||||
background-color: var(--nf-input-focus-border-color);
|
|
||||||
border-color: var(--nf-input-focus-border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="radio"] {
|
|
||||||
border-radius: 100%;
|
|
||||||
|
|
||||||
&:checked {
|
|
||||||
background-color: var(--nf-input-focus-border-color);
|
|
||||||
border-color: var(--nf-input-focus-border-color);
|
|
||||||
box-shadow: 0 0 0 3px white inset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------- SWITCH
|
|
||||||
|
|
||||||
--switch-orb-size: var(--nf-input-size);
|
|
||||||
--switch-orb-offset: calc(var(--nf-input-border-width) * 2);
|
|
||||||
--switch-width: calc(var(--nf-input-size) * 2.5);
|
|
||||||
--switch-height: calc(
|
|
||||||
calc(var(--nf-input-size) * 1.25) + var(--switch-orb-offset)
|
|
||||||
);
|
|
||||||
|
|
||||||
input[type="checkbox"].switch {
|
|
||||||
width: var(--switch-width);
|
|
||||||
height: var(--switch-height);
|
|
||||||
border-radius: var(--switch-height);
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
background: var(--nf-input-border-color);
|
|
||||||
border-radius: var(--switch-orb-size);
|
|
||||||
height: var(--switch-orb-size);
|
|
||||||
left: var(--switch-orb-offset);
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: var(--switch-orb-size);
|
|
||||||
content: "";
|
|
||||||
transition: all 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ label {
|
|
||||||
margin-top: calc(var(--switch-height) / 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:checked {
|
|
||||||
background: var(--nf-input-focus-border-color) none initial;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
transform: translateY(-50%) translateX(
|
|
||||||
calc(calc(var(--switch-width) / 2) - var(--switch-orb-offset))
|
|
||||||
);
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- FILE
|
|
||||||
|
|
||||||
input[type="file"] {
|
|
||||||
background: rgba(0, 0, 0, 0.025);
|
|
||||||
padding: calc(var(--nf-input-size) / 2);
|
|
||||||
display: block;
|
|
||||||
font-weight: normal;
|
|
||||||
width: 95%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: var(--nf-input-border-radius);
|
|
||||||
border: 1px dashed var(--nf-input-border-color);
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--nf-input-focus-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin button {
|
|
||||||
background: var(--nf-input-focus-border-color);
|
|
||||||
border: 0;
|
|
||||||
appearance: none;
|
|
||||||
border-radius: var(--nf-input-border-radius);
|
|
||||||
color: white;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::file-selector-button {
|
|
||||||
@include button();
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-file-upload-button {
|
|
||||||
@include button();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- SELECT
|
|
||||||
|
|
||||||
select,
|
|
||||||
.ts-wrapper.multi .ts-control,
|
|
||||||
.ts-wrapper.single .ts-control,
|
|
||||||
.ts-wrapper.single.input-active .ts-control {
|
|
||||||
background-color: var(--nf-input-background-color);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
|
||||||
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: var(--nf-input-size);
|
|
||||||
}
|
|
||||||
}
|
|
@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
> a {
|
>a {
|
||||||
color: $text-color!important;
|
color: $text-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover>a {
|
&:hover>a {
|
||||||
@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d;
|
|||||||
}
|
}
|
||||||
|
|
||||||
>input[type=text] {
|
>input[type=text] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: unset;
|
|
||||||
border: unset;
|
|
||||||
height: 35px;
|
height: 35px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-size: .9em;
|
font-size: .9em;
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,3 @@
|
|||||||
@import "core/static/core/colors";
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -71,7 +69,7 @@ main {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: $primary-neutral-light-color;
|
background-color: #f2f2f2;
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
font-size: small;
|
font-size: small;
|
||||||
|
@ -1,9 +1,26 @@
|
|||||||
|
|
||||||
@media (max-width: 750px) {
|
@media (max-width: 750px) {
|
||||||
.title {
|
.title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
height: auto !important;
|
||||||
|
|
||||||
|
> ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
color: indianred;
|
||||||
|
|
||||||
|
> li {
|
||||||
|
text-align: left !important;
|
||||||
|
line-height: normal;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile {
|
.profile {
|
||||||
&-visible {
|
&-visible {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -70,7 +87,11 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
> p {
|
> i {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>p {
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
@ -86,6 +107,16 @@
|
|||||||
> div {
|
> div {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
|
> input {
|
||||||
|
font-weight: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
min-width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 750px) {
|
@media (min-width: 750px) {
|
||||||
height: auto;
|
height: auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -93,8 +124,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
> input {
|
> input {
|
||||||
|
width: 70%;
|
||||||
font-size: .6em;
|
font-size: .6em;
|
||||||
|
|
||||||
&::file-selector-button {
|
&::file-selector-button {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
@ -136,7 +167,7 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
> * {
|
>* {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
|
|
||||||
@ -150,22 +181,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
> * {
|
|
||||||
|
>* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
|
line-height: 40px;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
> * {
|
>* {
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
height: 7rem;
|
>textarea {
|
||||||
}
|
height: 120px;
|
||||||
.final-actions {
|
min-height: 40px;
|
||||||
text-align: center;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -108,8 +108,7 @@
|
|||||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||||
</div>
|
</div>
|
||||||
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
|
<a href="https://discord.gg/XK9WfPsUFm" target="_link">
|
||||||
<i class="fa-brands fa-github"></i>
|
|
||||||
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -125,14 +124,15 @@
|
|||||||
navbar.style.setProperty("display", current === "none" ? "block" : "none");
|
navbar.style.setProperty("display", current === "none" ? "block" : "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
$(document).keydown(function (e) {
|
||||||
// Looking at the `s` key when not typing in a form
|
if ($(e.target).is('input')) { return }
|
||||||
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
|
if ($(e.target).is('textarea')) { return }
|
||||||
return;
|
if ($(e.target).is('select')) { return }
|
||||||
|
if (e.keyCode === 83) {
|
||||||
|
$("#search").focus();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
document.getElementById("search").focus();
|
});
|
||||||
e.preventDefault(); // Don't type the character in the focused search input
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
@ -57,4 +57,13 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
{{ super() }}
|
||||||
|
{% if popup %}
|
||||||
|
<script>
|
||||||
|
parent.$(".choose_file_widget").css("height", "75%");
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -60,18 +60,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.date_of_birth %}
|
{% if user.date_of_birth %}
|
||||||
<div class="user_mini_profile_dob">
|
<div class="user_mini_profile_dob">
|
||||||
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
|
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }})
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if user.promo and user.promo_has_logo() %}
|
{% if user.promo and user.promo_has_logo() %}
|
||||||
<div class="user_mini_profile_promo">
|
<div class="user_mini_profile_promo">
|
||||||
<img
|
<img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" />
|
||||||
src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
|
|
||||||
title="Promo {{ user.promo }}"
|
|
||||||
alt="Promo {{ user.promo }}"
|
|
||||||
class="promo_pict"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -79,11 +74,8 @@
|
|||||||
{% if user.profile_pict %}
|
{% if user.profile_pict %}
|
||||||
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
|
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<img
|
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
|
||||||
src="{{ static('core/img/unknown.jpg') }}"
|
title="{% trans %}Profile{% endtrans %}" />
|
||||||
alt="{% trans %}Profile{% endtrans %}"
|
|
||||||
title="{% trans %}Profile{% endtrans %}"
|
|
||||||
/>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -140,7 +132,7 @@
|
|||||||
nb_page (str): call to a javascript function or variable returning
|
nb_page (str): call to a javascript function or variable returning
|
||||||
the maximum number of pages to paginate
|
the maximum number of pages to paginate
|
||||||
#}
|
#}
|
||||||
<nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
|
<nav class="pagination" x-show="{{ nb_pages }} > 1">
|
||||||
{# Adding the prevent here is important, because otherwise,
|
{# Adding the prevent here is important, because otherwise,
|
||||||
clicking on the pagination buttons could submit the picture management form
|
clicking on the pagination buttons could submit the picture management form
|
||||||
and reload the page #}
|
and reload the page #}
|
||||||
@ -178,12 +170,12 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro paginate_htmx(current_page, paginator) %}
|
{% macro paginate_htmx(current_page, paginator) %}
|
||||||
{# Add pagination buttons for pages without Alpine but supporting fragments.
|
{# Add pagination buttons for pages without Alpine but supporting framgents.
|
||||||
|
|
||||||
This must be coupled with a view that handles pagination
|
This must be coupled with a view that handles pagination
|
||||||
with the Django Paginator object and supports fragments.
|
with the Django Paginator object and supports framgents.
|
||||||
|
|
||||||
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
|
The relpaced fragment will be #content so make sure you are calling this macro inside your content block.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
current_page (django.core.paginator.Page): the current page object
|
current_page (django.core.paginator.Page): the current page object
|
||||||
@ -255,9 +247,9 @@
|
|||||||
{% macro select_all_checkbox(form_id) %}
|
{% macro select_all_checkbox(form_id) %}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function checkbox_{{form_id}}(value) {
|
function checkbox_{{form_id}}(value) {
|
||||||
const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
||||||
for (let element of inputs){
|
for (let element of list){
|
||||||
if (element.type === "checkbox"){
|
if (element.type == "checkbox"){
|
||||||
element.checked = value;
|
element.checked = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -266,65 +258,3 @@
|
|||||||
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
|
<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>
|
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
|
||||||
{% endmacro %}
|
{% 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,18 +3,17 @@
|
|||||||
{% macro page_history(page) %}
|
{% macro page_history(page) %}
|
||||||
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
|
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
|
||||||
<ul>
|
<ul>
|
||||||
{% set page_name = page.get_full_name() %}
|
{% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
|
||||||
{%- for rev in page.revisions.order_by("-date").select_related("author") -%}
|
{% if loop.index < 2 %}
|
||||||
<li>
|
<li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
|
||||||
{% if loop.first %}
|
{{ user_profile_link(page.revisions.last().author) }} -
|
||||||
<a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
|
{{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
|
<li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
|
||||||
{% endif %}
|
{{ user_profile_link(r.author) }} -
|
||||||
{{ user_profile_link(rev.author) }} -
|
{{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
|
||||||
{{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
|
{% endif %}
|
||||||
</li>
|
{% endfor %}
|
||||||
{%- endfor -%}
|
|
||||||
</ul>
|
</ul>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
54
core/templates/core/poster_list.jinja
Normal file
54
core/templates/core/poster_list.jinja
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="{{ static('com/js/poster_list.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans %}Poster{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="poster_list">
|
||||||
|
|
||||||
|
<div id="title">
|
||||||
|
<h3>{% trans %}Posters{% endtrans %}</h3>
|
||||||
|
<div id="links" class="right">
|
||||||
|
<a id="create" class="link" href="{{ url(app + ":poster_list") }}">{% trans %}Create{% endtrans %}</a>
|
||||||
|
{% if app == "com" %}
|
||||||
|
<a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="posters">
|
||||||
|
|
||||||
|
{% if poster_list.count() == 0 %}
|
||||||
|
<div id="no-posters">{% trans %}No posters{% endtrans %}</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% for poster in poster_list %}
|
||||||
|
<div class="poster">
|
||||||
|
<div class="name">{{ poster.name }}</div>
|
||||||
|
<div class="image"><img src="{{ poster.file.url }}"></img></div>
|
||||||
|
<div class="dates">
|
||||||
|
<div class="begin">{{ poster.date_begin | date("d/M/Y H:m") }}</div>
|
||||||
|
<div class="end">{{ poster.date_end | date("d/M/Y H:m") }}</div>
|
||||||
|
</div>
|
||||||
|
<a class="edit" href="{{ url(poster_edit_url_name, poster.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="view"><div id="placeholder"></div></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -244,30 +244,27 @@
|
|||||||
{% block script %}
|
{% block script %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
// Image selection
|
$(function () {
|
||||||
for (const img of document.querySelectorAll("#small_pictures img")){
|
var keys = [];
|
||||||
img.addEventListener("click", (e) => {
|
var pattern = "71,85,89,71,85,89";
|
||||||
const displayed = document.querySelector("#big_picture img");
|
$(document).keydown(function (e) {
|
||||||
displayed.src = e.target.src;
|
keys.push(e.keyCode);
|
||||||
displayed.alt = e.target.alt;
|
if (keys.toString() == pattern) {
|
||||||
displayed.title = e.target.title;
|
keys = [];
|
||||||
})
|
$("#big_picture img").attr("src", "{{ static('core/img/yug.jpg') }}");
|
||||||
}
|
}
|
||||||
|
if (keys.length == 6) {
|
||||||
let keys = [];
|
keys.shift();
|
||||||
const pattern = "71,85,89,71,85,89";
|
}
|
||||||
|
});
|
||||||
document.addEventListener("keydown", (e) => {
|
});
|
||||||
keys.push(e.keyCode);
|
$(function () {
|
||||||
if (keys.toString() === pattern) {
|
$("#small_pictures img").click(function () {
|
||||||
keys = [];
|
$("#big_picture img").attr("src", $(this)[0].src);
|
||||||
document.querySelector("#big_picture img").src = "{{ static('core/img/yug.jpg') }}";
|
$("#big_picture img").attr("alt", $(this)[0].alt);
|
||||||
}
|
$("#big_picture img").attr("title", $(this)[0].title);
|
||||||
if (keys.length === 6) {
|
})
|
||||||
keys.shift();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
$("#drop_gifts").accordion({
|
$("#drop_gifts").accordion({
|
||||||
heightStyle: "content",
|
heightStyle: "content",
|
||||||
|
@ -63,7 +63,9 @@
|
|||||||
{%- trans -%}Delete{%- endtrans -%}
|
{%- trans -%}Delete{%- endtrans -%}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{{ form[field_name].label_tag() }}
|
<p>
|
||||||
|
{{ form[field_name].label }}
|
||||||
|
</p>
|
||||||
{{ form[field_name].errors }}
|
{{ form[field_name].errors }}
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
|
<em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
|
||||||
@ -116,68 +118,68 @@
|
|||||||
{# All fields #}
|
{# All fields #}
|
||||||
<div class="profile-fields">
|
<div class="profile-fields">
|
||||||
{%- for field in form -%}
|
{%- for field in form -%}
|
||||||
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
|
{%-
|
||||||
{%- continue -%}
|
if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"]
|
||||||
{%- endif -%}
|
-%}
|
||||||
|
{%- continue -%}
|
||||||
<div class="profile-field">
|
|
||||||
<div class="profile-field-label">{{ field.label }}</div>
|
|
||||||
<div class="profile-field-content">
|
|
||||||
{{ field }}
|
|
||||||
{%- if field.errors -%}
|
|
||||||
<div class="field-error">{{ field.errors }}</div>
|
|
||||||
{%- endif -%}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{%- endfor -%}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Textareas #}
|
|
||||||
<div class="profile-fields">
|
|
||||||
{%- for field in [form.quote, form.forum_signature] -%}
|
|
||||||
<div class="profile-field">
|
|
||||||
<div class="profile-field-label">{{ field.label }}</div>
|
|
||||||
<div class="profile-field-content">
|
|
||||||
{{ field }}
|
|
||||||
{%- if field.errors -%}
|
|
||||||
<div class="field-error">{{ field.errors }}</div>
|
|
||||||
{%- endif -%}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{%- endfor -%}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Checkboxes #}
|
|
||||||
<div class="profile-visible">
|
|
||||||
{{ form.is_subscriber_viewable }}
|
|
||||||
{{ form.is_subscriber_viewable.label }}
|
|
||||||
</div>
|
|
||||||
<div class="final-actions">
|
|
||||||
|
|
||||||
{%- if form.instance == user -%}
|
|
||||||
<p>
|
|
||||||
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
|
|
||||||
</p>
|
|
||||||
{%- elif user.is_root -%}
|
|
||||||
<p>
|
|
||||||
<a href="{{ url('core:password_root_change', user_id=form.instance.id) }}">
|
|
||||||
{%- trans -%}Change user password{%- endtrans -%}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|
||||||
<p>
|
<div class="profile-field">
|
||||||
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
|
<div class="profile-field-label">{{ field.label }}</div>
|
||||||
</p>
|
<div class="profile-field-content">
|
||||||
</div>
|
{{ field }}
|
||||||
</form>
|
{%- if field.errors -%}
|
||||||
|
<div class="field-error">{{ field.errors }}</div>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Textareas #}
|
||||||
|
<div class="profile-fields">
|
||||||
|
{%- for field in [form.quote, form.forum_signature] -%}
|
||||||
|
<div class="profile-field">
|
||||||
|
<div class="profile-field-label">{{ field.label }}</div>
|
||||||
|
<div class="profile-field-content">
|
||||||
|
{{ field }}
|
||||||
|
{%- if field.errors -%}
|
||||||
|
<div class="field-error">{{ field.errors }}</div>
|
||||||
|
{%- endif -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Checkboxes #}
|
||||||
|
<div class="profile-visible">
|
||||||
|
{{ form.is_subscriber_viewable }}
|
||||||
|
{{ form.is_subscriber_viewable.label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{%- if form.instance == user -%}
|
||||||
<p>
|
<p>
|
||||||
<em>{%- trans -%}Username: {%- endtrans -%} {{ form.instance.username }}</em>
|
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
|
||||||
<br />
|
|
||||||
{%- if form.instance.customer -%}
|
|
||||||
<em>{%- trans -%}Account number: {%- endtrans -%} {{ form.instance.customer.account_id }}</em>
|
|
||||||
{%- endif -%}
|
|
||||||
</p>
|
</p>
|
||||||
|
{%- elif user.is_root -%}
|
||||||
|
<p>
|
||||||
|
<a href="{{ url('core:password_root_change', user_id=form.instance.id) }}">
|
||||||
|
{%- trans -%}Change user password{%- endtrans -%}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<em>{%- trans -%}Username: {%- endtrans -%} {{ form.instance.username }}</em>
|
||||||
|
<br />
|
||||||
|
{%- if form.instance.customer -%}
|
||||||
|
<em>{%- trans -%}Account number: {%- endtrans -%} {{ form.instance.customer.account_id }}</em>
|
||||||
|
{%- endif -%}
|
||||||
|
</p>
|
||||||
|
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
@ -28,20 +28,42 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
|
<p>{% trans trombi=user.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
|
||||||
<br />
|
<br />
|
||||||
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
|
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% if student_card_fragment %}
|
{% if profile.customer %}
|
||||||
<h3>{% trans %}Student card{% endtrans %}</h3>
|
<h3>{% trans %}Student cards{% endtrans %}</h3>
|
||||||
{{ student_card_fragment }}
|
|
||||||
<p class="justify">
|
{% if profile.customer.student_cards.exists() %}
|
||||||
{% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually
|
<ul class="student-cards">
|
||||||
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
|
{% for card in profile.customer.student_cards.all() %}
|
||||||
</p>
|
<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>
|
||||||
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -23,9 +23,6 @@
|
|||||||
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
|
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
|
<li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.has_perm("core.view_userban") %}
|
|
||||||
<li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if user.can_create_subscription or user.is_root %}
|
{% if user.can_create_subscription or user.is_root %}
|
||||||
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
|
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -55,7 +52,7 @@
|
|||||||
%}
|
%}
|
||||||
<li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:producttype_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
|
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
|
||||||
|
@ -18,7 +18,6 @@ from smtplib import SMTPException
|
|||||||
|
|
||||||
import freezegun
|
import freezegun
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.hashers import make_password
|
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
@ -31,7 +30,7 @@ from model_bakery import baker
|
|||||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||||
|
|
||||||
from antispam.models import ToxicDomain
|
from antispam.models import ToxicDomain
|
||||||
from club.models import Club, Membership
|
from club.models import Membership
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import AnonymousUser, Group, Page, User
|
from core.models import AnonymousUser, Group, Page, User
|
||||||
from core.utils import get_semester_code, get_start_of_semester
|
from core.utils import get_semester_code, get_start_of_semester
|
||||||
@ -119,9 +118,7 @@ class TestUserRegistration:
|
|||||||
response = client.post(reverse("core:register"), valid_payload)
|
response = client.post(reverse("core:register"), valid_payload)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
error_html = (
|
error_html = "<li>Un objet User avec ce champ Adresse email existe déjà.</li>"
|
||||||
"<li>Un objet Utilisateur avec ce champ Adresse email existe déjà.</li>"
|
|
||||||
)
|
|
||||||
assertInHTML(error_html, str(response.content.decode()))
|
assertInHTML(error_html, str(response.content.decode()))
|
||||||
|
|
||||||
def test_register_fail_with_not_existing_email(
|
def test_register_fail_with_not_existing_email(
|
||||||
@ -146,7 +143,7 @@ class TestUserRegistration:
|
|||||||
class TestUserLogin:
|
class TestUserLogin:
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def user(self) -> User:
|
def user(self) -> User:
|
||||||
return baker.make(User, password=make_password("plop"))
|
return User.objects.first()
|
||||||
|
|
||||||
def test_login_fail(self, client, user):
|
def test_login_fail(self, client, user):
|
||||||
"""Should not login a user correctly."""
|
"""Should not login a user correctly."""
|
||||||
@ -350,35 +347,56 @@ class TestUserIsInGroup(TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
from club.models import Club
|
||||||
|
|
||||||
cls.root_group = Group.objects.get(name="Root")
|
cls.root_group = Group.objects.get(name="Root")
|
||||||
cls.public_group = Group.objects.get(name="Public")
|
cls.public = Group.objects.get(name="Public")
|
||||||
cls.public_user = baker.make(User)
|
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.subscribers = Group.objects.get(name="Subscribers")
|
cls.subscribers = Group.objects.get(name="Subscribers")
|
||||||
cls.old_subscribers = Group.objects.get(name="Old subscribers")
|
cls.old_subscribers = Group.objects.get(name="Old subscribers")
|
||||||
cls.accounting_admin = Group.objects.get(name="Accounting admin")
|
cls.accounting_admin = Group.objects.get(name="Accounting admin")
|
||||||
cls.com_admin = Group.objects.get(name="Communication admin")
|
cls.com_admin = Group.objects.get(name="Communication admin")
|
||||||
cls.counter_admin = Group.objects.get(name="Counter admin")
|
cls.counter_admin = Group.objects.get(name="Counter admin")
|
||||||
|
cls.banned_alcohol = Group.objects.get(name="Banned from buying alcohol")
|
||||||
|
cls.banned_counters = Group.objects.get(name="Banned from counters")
|
||||||
|
cls.banned_subscription = Group.objects.get(name="Banned to subscribe")
|
||||||
cls.sas_admin = Group.objects.get(name="SAS admin")
|
cls.sas_admin = Group.objects.get(name="SAS admin")
|
||||||
cls.club = baker.make(Club)
|
cls.club = Club.objects.create(
|
||||||
|
name="Fake Club",
|
||||||
|
unix_name="fake-club",
|
||||||
|
address="Fake address",
|
||||||
|
)
|
||||||
cls.main_club = Club.objects.get(id=1)
|
cls.main_club = Club.objects.get(id=1)
|
||||||
|
|
||||||
def assert_in_public_group(self, user):
|
def assert_in_public_group(self, user):
|
||||||
assert user.is_in_group(pk=self.public_group.id)
|
assert user.is_in_group(pk=self.public.id)
|
||||||
assert user.is_in_group(name=self.public_group.name)
|
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
|
||||||
|
|
||||||
def assert_only_in_public_group(self, user):
|
def assert_only_in_public_group(self, user):
|
||||||
self.assert_in_public_group(user)
|
self.assert_in_public_group(user)
|
||||||
for group in (
|
for group in (
|
||||||
self.root_group,
|
self.root_group,
|
||||||
|
self.banned_counters,
|
||||||
self.accounting_admin,
|
self.accounting_admin,
|
||||||
self.sas_admin,
|
self.sas_admin,
|
||||||
self.subscribers,
|
self.subscribers,
|
||||||
self.old_subscribers,
|
self.old_subscribers,
|
||||||
self.club.members_group,
|
|
||||||
self.club.board_group,
|
|
||||||
):
|
):
|
||||||
assert not user.is_in_group(pk=group.pk)
|
assert not user.is_in_group(pk=group.pk)
|
||||||
assert not user.is_in_group(name=group.name)
|
assert not user.is_in_group(name=group.name)
|
||||||
|
meta_groups_board = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
||||||
|
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||||
|
assert user.is_in_group(name=meta_groups_board) is False
|
||||||
|
assert user.is_in_group(name=meta_groups_members) is False
|
||||||
|
|
||||||
def test_anonymous_user(self):
|
def test_anonymous_user(self):
|
||||||
"""Test that anonymous users are only in the public group."""
|
"""Test that anonymous users are only in the public group."""
|
||||||
@ -387,80 +405,80 @@ class TestUserIsInGroup(TestCase):
|
|||||||
|
|
||||||
def test_not_subscribed_user(self):
|
def test_not_subscribed_user(self):
|
||||||
"""Test that users who never subscribed are only in the public group."""
|
"""Test that users who never subscribed are only in the public group."""
|
||||||
self.assert_only_in_public_group(self.public_user)
|
self.assert_only_in_public_group(self.toto)
|
||||||
|
|
||||||
def test_wrong_parameter_fail(self):
|
def test_wrong_parameter_fail(self):
|
||||||
"""Test that when neither the pk nor the name argument is given,
|
"""Test that when neither the pk nor the name argument is given,
|
||||||
the function raises a ValueError.
|
the function raises a ValueError.
|
||||||
"""
|
"""
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
self.public_user.is_in_group()
|
self.toto.is_in_group()
|
||||||
|
|
||||||
def test_number_queries(self):
|
def test_number_queries(self):
|
||||||
"""Test that the number of db queries is stable
|
"""Test that the number of db queries is stable
|
||||||
and that less queries are made when making a new call.
|
and that less queries are made when making a new call.
|
||||||
"""
|
"""
|
||||||
# make sure Skia is in at least one group
|
# make sure Skia is in at least one group
|
||||||
group_in = baker.make(Group)
|
self.skia.groups.add(Group.objects.first().pk)
|
||||||
self.public_user.groups.add(group_in)
|
skia_groups = self.skia.groups.all()
|
||||||
|
|
||||||
|
group_in = skia_groups.first()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
# Test when the user is in the group
|
# Test when the user is in the group
|
||||||
with self.assertNumQueries(2):
|
with self.assertNumQueries(2):
|
||||||
self.public_user.is_in_group(pk=group_in.id)
|
self.skia.is_in_group(pk=group_in.id)
|
||||||
with self.assertNumQueries(0):
|
with self.assertNumQueries(0):
|
||||||
self.public_user.is_in_group(pk=group_in.id)
|
self.skia.is_in_group(pk=group_in.id)
|
||||||
|
|
||||||
group_not_in = baker.make(Group)
|
ids = skia_groups.values_list("pk", flat=True)
|
||||||
|
group_not_in = Group.objects.exclude(pk__in=ids).first()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
# Test when the user is not in the group
|
# Test when the user is not in the group
|
||||||
with self.assertNumQueries(2):
|
with self.assertNumQueries(2):
|
||||||
self.public_user.is_in_group(pk=group_not_in.id)
|
self.skia.is_in_group(pk=group_not_in.id)
|
||||||
with self.assertNumQueries(0):
|
with self.assertNumQueries(0):
|
||||||
self.public_user.is_in_group(pk=group_not_in.id)
|
self.skia.is_in_group(pk=group_not_in.id)
|
||||||
|
|
||||||
def test_cache_properly_cleared_membership(self):
|
def test_cache_properly_cleared_membership(self):
|
||||||
"""Test that when the membership of a user end,
|
"""Test that when the membership of a user end,
|
||||||
the cache is properly invalidated.
|
the cache is properly invalidated.
|
||||||
"""
|
"""
|
||||||
membership = baker.make(Membership, club=self.club, user=self.public_user)
|
membership = Membership.objects.create(
|
||||||
cache.clear()
|
club=self.club, user=self.toto, end_date=None
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
|
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||||
|
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}")
|
||||||
membership.end_date = now() - timedelta(minutes=5)
|
membership.end_date = now() - timedelta(minutes=5)
|
||||||
membership.save()
|
membership.save()
|
||||||
cached_membership = cache.get(
|
cached_membership = cache.get(f"membership_{self.club.id}_{self.toto.id}")
|
||||||
f"membership_{self.club.id}_{self.public_user.id}"
|
|
||||||
)
|
|
||||||
assert cached_membership == "not_member"
|
assert cached_membership == "not_member"
|
||||||
|
assert self.toto.is_in_group(name=meta_groups_members) is False
|
||||||
|
|
||||||
def test_cache_properly_cleared_group(self):
|
def test_cache_properly_cleared_group(self):
|
||||||
"""Test that when a user is removed from a group,
|
"""Test that when a user is removed from a group,
|
||||||
the is_in_group_method return False when calling it again.
|
the is_in_group_method return False when calling it again.
|
||||||
"""
|
"""
|
||||||
# testing with pk
|
# testing with pk
|
||||||
self.public_user.groups.add(self.com_admin.pk)
|
self.toto.groups.add(self.com_admin.pk)
|
||||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
|
assert self.toto.is_in_group(pk=self.com_admin.pk) is True
|
||||||
|
|
||||||
self.public_user.groups.remove(self.com_admin.pk)
|
self.toto.groups.remove(self.com_admin.pk)
|
||||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
|
assert self.toto.is_in_group(pk=self.com_admin.pk) is False
|
||||||
|
|
||||||
# testing with name
|
# testing with name
|
||||||
self.public_user.groups.add(self.sas_admin.pk)
|
self.toto.groups.add(self.sas_admin.pk)
|
||||||
assert self.public_user.is_in_group(name="SAS admin") is True
|
assert self.toto.is_in_group(name="SAS admin") is True
|
||||||
|
|
||||||
self.public_user.groups.remove(self.sas_admin.pk)
|
self.toto.groups.remove(self.sas_admin.pk)
|
||||||
assert self.public_user.is_in_group(name="SAS admin") is False
|
assert self.toto.is_in_group(name="SAS admin") is False
|
||||||
|
|
||||||
def test_not_existing_group(self):
|
def test_not_existing_group(self):
|
||||||
"""Test that searching for a not existing group
|
"""Test that searching for a not existing group
|
||||||
returns False.
|
returns False.
|
||||||
"""
|
"""
|
||||||
user = baker.make(User)
|
assert self.skia.is_in_group(name="This doesn't exist") is False
|
||||||
user.groups.set(list(Group.objects.all()))
|
|
||||||
assert not user.is_in_group(name="This doesn't exist")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDateUtils(TestCase):
|
class TestDateUtils(TestCase):
|
||||||
|
@ -14,7 +14,7 @@ from PIL import Image
|
|||||||
from pytest_django.asserts import assertNumQueries
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, SithFile, User
|
from core.models import Group, RealGroup, SithFile, User
|
||||||
from sas.models import Picture
|
from sas.models import Picture
|
||||||
from sith import settings
|
from sith import settings
|
||||||
|
|
||||||
@ -26,10 +26,12 @@ class TestImageAccess:
|
|||||||
[
|
[
|
||||||
lambda: baker.make(User, is_superuser=True),
|
lambda: baker.make(User, is_superuser=True),
|
||||||
lambda: baker.make(
|
lambda: baker.make(
|
||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
User,
|
||||||
|
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
|
||||||
),
|
),
|
||||||
lambda: baker.make(
|
lambda: baker.make(
|
||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
|
User,
|
||||||
|
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -13,41 +13,22 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
# Image utils
|
# Image utils
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any
|
from typing import Optional
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.forms import BaseForm
|
|
||||||
from django.http import HttpRequest
|
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 django.utils.timezone import localdate
|
||||||
from PIL import ExifTags
|
from PIL import ExifTags
|
||||||
from PIL.Image import Image, Resampling
|
from PIL.Image import Image, Resampling
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
def get_start_of_semester(today: Optional[date] = None) -> date:
|
||||||
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.
|
"""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.
|
If no date is given, return the start date of the current semester.
|
||||||
|
|
||||||
@ -77,7 +58,7 @@ def get_start_of_semester(today: date | None = None) -> date:
|
|||||||
return autumn.replace(year=autumn.year - 1)
|
return autumn.replace(year=autumn.year - 1)
|
||||||
|
|
||||||
|
|
||||||
def get_semester_code(d: date | None = None) -> str:
|
def get_semester_code(d: Optional[date] = None) -> str:
|
||||||
"""Return the semester code of the given date.
|
"""Return the semester code of the given date.
|
||||||
If no date is given, return the semester code of the current semester.
|
If no date is given, return the semester code of the current semester.
|
||||||
|
|
||||||
|
@ -13,7 +13,6 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import quote, urljoin
|
from urllib.parse import quote, urljoin
|
||||||
|
|
||||||
# This file contains all the views that concern the page model
|
# This file contains all the views that concern the page model
|
||||||
@ -22,7 +21,6 @@ from wsgiref.util import FileWrapper
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db.models import Exists, OuterRef
|
|
||||||
from django.forms.models import modelform_factory
|
from django.forms.models import modelform_factory
|
||||||
from django.http import Http404, HttpRequest, HttpResponse
|
from django.http import Http404, HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
@ -33,7 +31,7 @@ from django.views.generic import DetailView, ListView
|
|||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
|
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
|
||||||
|
|
||||||
from core.models import Notification, SithFile, User
|
from core.models import Notification, RealGroup, SithFile, User
|
||||||
from core.views import (
|
from core.views import (
|
||||||
AllowFragment,
|
AllowFragment,
|
||||||
CanEditMixin,
|
CanEditMixin,
|
||||||
@ -49,41 +47,6 @@ from core.views.widgets.select import (
|
|||||||
from counter.utils import is_logged_in_counter
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
def send_raw_file(path: Path) -> HttpResponse:
|
|
||||||
"""Send a file located in the MEDIA_ROOT
|
|
||||||
|
|
||||||
This handles all the logic of using production reverse proxy or debug server.
|
|
||||||
|
|
||||||
THIS DOESN'T CHECK ANY PERMISSIONS !
|
|
||||||
"""
|
|
||||||
if not path.is_relative_to(settings.MEDIA_ROOT):
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
if not path.is_file() or not path.exists():
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
response = HttpResponse(
|
|
||||||
headers={"Content-Disposition": f'inline; filename="{quote(path.name)}"'}
|
|
||||||
)
|
|
||||||
if not settings.DEBUG:
|
|
||||||
# When receiving a response with the Accel-Redirect header,
|
|
||||||
# the reverse proxy will automatically handle the file sending.
|
|
||||||
# This is really hard to test (thus isn't tested)
|
|
||||||
# so please do not mess with this.
|
|
||||||
response["Content-Type"] = "" # automatically set by nginx
|
|
||||||
response["X-Accel-Redirect"] = quote(
|
|
||||||
urljoin(settings.MEDIA_URL, str(path.relative_to(settings.MEDIA_ROOT)))
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
with open(path, "rb") as filename:
|
|
||||||
response.content = FileWrapper(filename)
|
|
||||||
response["Content-Type"] = mimetypes.guess_type(path)[0]
|
|
||||||
response["Last-Modified"] = http_date(path.stat().st_mtime)
|
|
||||||
response["Content-Length"] = path.stat().st_size
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def send_file(
|
def send_file(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
file_id: int,
|
file_id: int,
|
||||||
@ -102,7 +65,28 @@ def send_file(
|
|||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
name = getattr(f, file_attr).name
|
name = getattr(f, file_attr).name
|
||||||
|
|
||||||
return send_raw_file(settings.MEDIA_ROOT / 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
|
||||||
|
|
||||||
|
|
||||||
class MultipleFileInput(forms.ClearableFileInput):
|
class MultipleFileInput(forms.ClearableFileInput):
|
||||||
@ -175,18 +159,19 @@ class AddFilesForm(forms.Form):
|
|||||||
% {"file_name": f, "msg": repr(e)},
|
% {"file_name": f, "msg": repr(e)},
|
||||||
)
|
)
|
||||||
if notif:
|
if notif:
|
||||||
unread_notif_subquery = Notification.objects.filter(
|
for u in (
|
||||||
user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
|
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
)
|
.first()
|
||||||
for user in User.objects.filter(
|
.users.all()
|
||||||
~Exists(unread_notif_subquery),
|
|
||||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
|
||||||
):
|
):
|
||||||
Notification.objects.create(
|
if not u.notifications.filter(
|
||||||
user=user,
|
type="FILE_MODERATION", viewed=False
|
||||||
url=reverse("core:file_moderation"),
|
).exists():
|
||||||
type="FILE_MODERATION",
|
Notification(
|
||||||
)
|
user=u,
|
||||||
|
url=reverse("core:file_moderation"),
|
||||||
|
type="FILE_MODERATION",
|
||||||
|
).save()
|
||||||
|
|
||||||
|
|
||||||
class FileListView(ListView):
|
class FileListView(ListView):
|
||||||
|
@ -21,7 +21,6 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
import re
|
import re
|
||||||
from datetime import date, datetime
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from captcha.fields import CaptchaField
|
from captcha.fields import CaptchaField
|
||||||
@ -38,16 +37,14 @@ from django.forms import (
|
|||||||
DateInput,
|
DateInput,
|
||||||
DateTimeInput,
|
DateTimeInput,
|
||||||
TextInput,
|
TextInput,
|
||||||
Widget,
|
|
||||||
)
|
)
|
||||||
from django.utils.timezone import now
|
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from antispam.forms import AntiSpamEmailField
|
from antispam.forms import AntiSpamEmailField
|
||||||
from core.models import Gift, Group, Page, SithFile, User
|
from core.models import Gift, Page, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
from core.views.widgets.select import (
|
from core.views.widgets.select import (
|
||||||
AutoCompleteSelect,
|
AutoCompleteSelect,
|
||||||
@ -133,23 +130,6 @@ class SelectUser(TextInput):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
# Fields
|
|
||||||
|
|
||||||
|
|
||||||
def validate_future_timestamp(value: date | datetime):
|
|
||||||
if value <= now():
|
|
||||||
raise ValueError(_("Ensure this timestamp is set in the future"))
|
|
||||||
|
|
||||||
|
|
||||||
class FutureDateTimeField(forms.DateTimeField):
|
|
||||||
"""A datetime field that accepts only future timestamps."""
|
|
||||||
|
|
||||||
default_validators = [validate_future_timestamp]
|
|
||||||
|
|
||||||
def widget_attrs(self, widget: Widget) -> dict[str, str]:
|
|
||||||
return {"min": widget.format_value(now())}
|
|
||||||
|
|
||||||
|
|
||||||
# Forms
|
# Forms
|
||||||
|
|
||||||
|
|
||||||
@ -187,15 +167,14 @@ class RegisteringForm(UserCreationForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ("first_name", "last_name", "email")
|
fields = ("first_name", "last_name", "email")
|
||||||
field_classes = {"email": AntiSpamEmailField}
|
field_classes = {
|
||||||
|
"email": AntiSpamEmailField,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(forms.ModelForm):
|
class UserProfileForm(forms.ModelForm):
|
||||||
"""Form handling the user profile, managing the files"""
|
"""Form handling the user profile, managing the files"""
|
||||||
|
|
||||||
required_css_class = "required"
|
|
||||||
error_css_class = "error"
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
@ -308,20 +287,15 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
self._post_clean()
|
self._post_clean()
|
||||||
|
|
||||||
|
|
||||||
class UserGroupsForm(forms.ModelForm):
|
class UserPropForm(forms.ModelForm):
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
|
|
||||||
groups = forms.ModelMultipleChoiceField(
|
|
||||||
queryset=Group.objects.filter(is_manually_manageable=True),
|
|
||||||
widget=CheckboxSelectMultiple,
|
|
||||||
label=_("Groups"),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ["groups"]
|
fields = ["groups"]
|
||||||
|
help_texts = {"groups": "Which groups this user belongs to"}
|
||||||
|
widgets = {"groups": CheckboxSelectMultiple}
|
||||||
|
|
||||||
|
|
||||||
class UserGodfathersForm(forms.Form):
|
class UserGodfathersForm(forms.Form):
|
||||||
|
@ -21,9 +21,11 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||||
|
|
||||||
from core.models import Group, User
|
from core.models import RealGroup, User
|
||||||
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
|
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
|
||||||
from core.views.widgets.select import AutoCompleteSelectMultipleUser
|
from core.views.widgets.select import (
|
||||||
|
AutoCompleteSelectMultipleUser,
|
||||||
|
)
|
||||||
|
|
||||||
# Forms
|
# Forms
|
||||||
|
|
||||||
@ -57,8 +59,7 @@ class EditMembersForm(forms.Form):
|
|||||||
class GroupListView(CanEditMixin, ListView):
|
class GroupListView(CanEditMixin, ListView):
|
||||||
"""Displays the Group list."""
|
"""Displays the Group list."""
|
||||||
|
|
||||||
model = Group
|
model = RealGroup
|
||||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
template_name = "core/group_list.jinja"
|
template_name = "core/group_list.jinja"
|
||||||
|
|
||||||
@ -66,8 +67,7 @@ class GroupListView(CanEditMixin, ListView):
|
|||||||
class GroupEditView(CanEditMixin, UpdateView):
|
class GroupEditView(CanEditMixin, UpdateView):
|
||||||
"""Edit infos of a Group."""
|
"""Edit infos of a Group."""
|
||||||
|
|
||||||
model = Group
|
model = RealGroup
|
||||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
|
||||||
pk_url_kwarg = "group_id"
|
pk_url_kwarg = "group_id"
|
||||||
template_name = "core/group_edit.jinja"
|
template_name = "core/group_edit.jinja"
|
||||||
fields = ["name", "description"]
|
fields = ["name", "description"]
|
||||||
@ -76,8 +76,7 @@ class GroupEditView(CanEditMixin, UpdateView):
|
|||||||
class GroupCreateView(CanCreateMixin, CreateView):
|
class GroupCreateView(CanCreateMixin, CreateView):
|
||||||
"""Add a new Group."""
|
"""Add a new Group."""
|
||||||
|
|
||||||
model = Group
|
model = RealGroup
|
||||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
fields = ["name", "description"]
|
fields = ["name", "description"]
|
||||||
|
|
||||||
@ -87,8 +86,7 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
|||||||
Allow adding and removing users from it.
|
Allow adding and removing users from it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = Group
|
model = RealGroup
|
||||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
|
||||||
form_class = EditMembersForm
|
form_class = EditMembersForm
|
||||||
pk_url_kwarg = "group_id"
|
pk_url_kwarg = "group_id"
|
||||||
template_name = "core/group_detail.jinja"
|
template_name = "core/group_detail.jinja"
|
||||||
@ -122,8 +120,7 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
|||||||
class GroupDeleteView(CanEditMixin, DeleteView):
|
class GroupDeleteView(CanEditMixin, DeleteView):
|
||||||
"""Delete a Group."""
|
"""Delete a Group."""
|
||||||
|
|
||||||
model = Group
|
model = RealGroup
|
||||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
|
||||||
pk_url_kwarg = "group_id"
|
pk_url_kwarg = "group_id"
|
||||||
template_name = "core/delete_confirm.jinja"
|
template_name = "core/delete_confirm.jinja"
|
||||||
success_url = reverse_lazy("core:group_list")
|
success_url = reverse_lazy("core:group_list")
|
||||||
|
@ -64,20 +64,16 @@ class PageView(CanViewMixin, DetailView):
|
|||||||
class PageHistView(CanViewMixin, DetailView):
|
class PageHistView(CanViewMixin, DetailView):
|
||||||
model = Page
|
model = Page
|
||||||
template_name = "core/page_hist.jinja"
|
template_name = "core/page_hist.jinja"
|
||||||
slug_field = "_full_name"
|
|
||||||
slug_url_kwarg = "page_name"
|
|
||||||
_cached_object: Page | None = None
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
page = self.get_object()
|
res = super().dispatch(request, *args, **kwargs)
|
||||||
if page.need_club_redirection:
|
if self.object.need_club_redirection:
|
||||||
return redirect("club:club_hist", club_id=page.club.id)
|
return redirect("club:club_hist", club_id=self.object.club.id)
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return res
|
||||||
|
|
||||||
def get_object(self, *args, **kwargs):
|
def get_object(self):
|
||||||
if not self._cached_object:
|
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
|
||||||
self._cached_object = super().get_object()
|
return self.page
|
||||||
return self._cached_object
|
|
||||||
|
|
||||||
|
|
||||||
class PageRevView(CanViewMixin, DetailView):
|
class PageRevView(CanViewMixin, DetailView):
|
||||||
|
@ -35,6 +35,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db.models import DateField, QuerySet
|
from django.db.models import DateField, QuerySet
|
||||||
from django.db.models.functions import Trunc
|
from django.db.models.functions import Trunc
|
||||||
|
from django.forms import CheckboxSelectMultiple
|
||||||
from django.forms.models import modelform_factory
|
from django.forms.models import modelform_factory
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
@ -67,11 +68,10 @@ from core.views.forms import (
|
|||||||
LoginForm,
|
LoginForm,
|
||||||
RegisteringForm,
|
RegisteringForm,
|
||||||
UserGodfathersForm,
|
UserGodfathersForm,
|
||||||
UserGroupsForm,
|
|
||||||
UserProfileForm,
|
UserProfileForm,
|
||||||
)
|
)
|
||||||
|
from counter.forms import StudentCardForm
|
||||||
from counter.models import Refilling, Selling
|
from counter.models import Refilling, Selling
|
||||||
from counter.views.student_card import StudentCardFormView
|
|
||||||
from eboutic.models import Invoice
|
from eboutic.models import Invoice
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
from trombi.views import UserTrombiForm
|
from trombi.views import UserTrombiForm
|
||||||
@ -559,6 +559,10 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
|
|||||||
context_object_name = "profile"
|
context_object_name = "profile"
|
||||||
current_tab = "prefs"
|
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):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
pref = self.object.preferences
|
pref = self.object.preferences
|
||||||
@ -568,12 +572,13 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
if not hasattr(self.object, "trombi_user"):
|
if not (
|
||||||
|
hasattr(self.object, "trombi_user") and self.request.user.trombi_user.trombi
|
||||||
|
):
|
||||||
kwargs["trombi_form"] = UserTrombiForm()
|
kwargs["trombi_form"] = UserTrombiForm()
|
||||||
|
|
||||||
if hasattr(self.object, "customer"):
|
if hasattr(self.object, "customer"):
|
||||||
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
|
kwargs["student_card_form"] = StudentCardForm()
|
||||||
self.object.customer
|
|
||||||
).render(self.request)
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
@ -583,7 +588,9 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
|
|||||||
model = User
|
model = User
|
||||||
pk_url_kwarg = "user_id"
|
pk_url_kwarg = "user_id"
|
||||||
template_name = "core/user_group.jinja"
|
template_name = "core/user_group.jinja"
|
||||||
form_class = UserGroupsForm
|
form_class = modelform_factory(
|
||||||
|
User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple}
|
||||||
|
)
|
||||||
context_object_name = "profile"
|
context_object_name = "profile"
|
||||||
current_tab = "groups"
|
current_tab = "groups"
|
||||||
|
|
||||||
|
@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
|
|||||||
|
|
||||||
@admin.register(ProductType)
|
@admin.register(ProductType)
|
||||||
class ProductTypeAdmin(admin.ModelAdmin):
|
class ProductTypeAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "order")
|
list_display = ("name", "priority")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CashRegisterSummary)
|
@admin.register(CashRegisterSummary)
|
||||||
|
115
counter/api.py
115
counter/api.py
@ -12,49 +12,41 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from django.conf import settings
|
from typing import Annotated
|
||||||
from django.db.models import F
|
|
||||||
from django.shortcuts import get_object_or_404
|
from annotated_types import MinLen
|
||||||
|
from django.db.models import Q
|
||||||
from ninja import Query
|
from ninja import Query
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
|
from ninja_extra.permissions import IsAuthenticated
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
from core.api_permissions import CanAccessLookup, CanView, IsRoot
|
||||||
from counter.models import Counter, Product, ProductType
|
from counter.models import Counter, Permanency, Product
|
||||||
from counter.schemas import (
|
from counter.schemas import (
|
||||||
CounterFilterSchema,
|
CounterFilterSchema,
|
||||||
CounterSchema,
|
CounterSchema,
|
||||||
ProductFilterSchema,
|
PermanencyFilterSchema,
|
||||||
|
PermanencySchema,
|
||||||
ProductSchema,
|
ProductSchema,
|
||||||
ProductTypeSchema,
|
|
||||||
ReorderProductTypeSchema,
|
|
||||||
SimpleProductSchema,
|
|
||||||
SimplifiedCounterSchema,
|
SimplifiedCounterSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
IsCounterAdmin = (
|
|
||||||
IsRoot
|
|
||||||
| IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
|
||||||
| IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/counter")
|
@api_controller("/counter")
|
||||||
class CounterController(ControllerBase):
|
class CounterController(ControllerBase):
|
||||||
@route.get("", response=list[CounterSchema], permissions=[IsRoot])
|
@route.get("", response=list[CounterSchema], permissions=[IsRoot])
|
||||||
def fetch_all(self):
|
def fetch_all(self):
|
||||||
return Counter.objects.annotate_is_open()
|
return Counter.objects.all()
|
||||||
|
|
||||||
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
|
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
|
||||||
def fetch_one(self, counter_id: int):
|
def fetch_one(self, counter_id: int):
|
||||||
return self.get_object_or_exception(
|
return self.get_object_or_exception(Counter.objects.all(), pk=counter_id)
|
||||||
Counter.objects.annotate_is_open(), pk=counter_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@route.get("bar/", response=list[CounterSchema], permissions=[CanView])
|
@route.get("bar/", response=list[CounterSchema], permissions=[CanView])
|
||||||
def fetch_bars(self):
|
def fetch_bars(self):
|
||||||
counters = list(Counter.objects.annotate_is_open().filter(type="BAR"))
|
counters = list(Counter.objects.all().filter(type="BAR"))
|
||||||
for c in counters:
|
for c in counters:
|
||||||
self.check_object_permissions(c)
|
self.check_object_permissions(c)
|
||||||
return counters
|
return counters
|
||||||
@ -73,72 +65,33 @@ class CounterController(ControllerBase):
|
|||||||
class ProductController(ControllerBase):
|
class ProductController(ControllerBase):
|
||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SimpleProductSchema],
|
response=PaginatedResponseSchema[ProductSchema],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_products(self, filters: Query[ProductFilterSchema]):
|
def search_products(self, search: Annotated[str, MinLen(1)]):
|
||||||
return filters.filter(
|
return (
|
||||||
Product.objects.order_by(
|
Product.objects.filter(
|
||||||
F("product_type__order").asc(nulls_last=True),
|
Q(name__icontains=search) | Q(code__icontains=search)
|
||||||
"product_type",
|
|
||||||
"name",
|
|
||||||
).values()
|
|
||||||
)
|
|
||||||
|
|
||||||
@route.get(
|
|
||||||
"/search/detailed",
|
|
||||||
response=PaginatedResponseSchema[ProductSchema],
|
|
||||||
permissions=[IsCounterAdmin],
|
|
||||||
url_name="search_products_detailed",
|
|
||||||
)
|
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
|
||||||
def search_products_detailed(self, filters: Query[ProductFilterSchema]):
|
|
||||||
"""Get the detailed information about the products."""
|
|
||||||
return filters.filter(
|
|
||||||
Product.objects.select_related("club")
|
|
||||||
.prefetch_related("buying_groups")
|
|
||||||
.select_related("product_type")
|
|
||||||
.order_by(
|
|
||||||
F("product_type__order").asc(nulls_last=True),
|
|
||||||
"product_type",
|
|
||||||
"name",
|
|
||||||
)
|
)
|
||||||
|
.filter(archived=False)
|
||||||
|
.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/product-type", permissions=[IsCounterAdmin])
|
@api_controller("/permanency")
|
||||||
class ProductTypeController(ControllerBase):
|
class PermanencyController(ControllerBase):
|
||||||
@route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types")
|
@route.get(
|
||||||
def fetch_all(self):
|
"",
|
||||||
return ProductType.objects.order_by("order")
|
response=PaginatedResponseSchema[PermanencySchema],
|
||||||
|
permissions=[IsAuthenticated],
|
||||||
@route.patch("/{type_id}/move")
|
exclude_none=True,
|
||||||
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
|
)
|
||||||
"""Change the order of a product type.
|
@paginate(PageNumberPaginationExtra, page_size=100)
|
||||||
|
def fetch_permanencies(self, filters: Query[PermanencyFilterSchema]):
|
||||||
To use this route, give either the id of the product type
|
return (
|
||||||
this one should be above of,
|
filters.filter(Permanency.objects.all())
|
||||||
of the id of the product type this one should be below of.
|
.distinct()
|
||||||
|
.order_by("-start")
|
||||||
Order affects the display order of the product types.
|
.select_related("counter")
|
||||||
|
|
||||||
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,12 +24,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
PAYMENT_METHOD = [
|
|
||||||
("CHECK", _("Check")),
|
|
||||||
("CASH", _("Cash")),
|
|
||||||
("CARD", _("Credit card")),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CounterConfig(AppConfig):
|
class CounterConfig(AppConfig):
|
||||||
name = "counter"
|
name = "counter"
|
||||||
|
@ -45,14 +45,16 @@ class BillingInfoForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class StudentCardForm(forms.ModelForm):
|
class StudentCardForm(forms.ModelForm):
|
||||||
"""Form for adding student cards"""
|
"""Form for adding student cards
|
||||||
|
Only used for user profile since CounterClick is to complicated.
|
||||||
error_css_class = "error"
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StudentCard
|
model = StudentCard
|
||||||
fields = ["uid"]
|
fields = ["uid"]
|
||||||
widgets = {"uid": NFCTextInput}
|
widgets = {
|
||||||
|
"uid": NFCTextInput,
|
||||||
|
}
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
@ -89,7 +91,7 @@ class GetUserForm(forms.Form):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
customer = None
|
cus = None
|
||||||
if cleaned_data["code"] != "":
|
if cleaned_data["code"] != "":
|
||||||
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
|
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
|
||||||
card = (
|
card = (
|
||||||
@ -98,24 +100,29 @@ class GetUserForm(forms.Form):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if card is not None:
|
if card is not None:
|
||||||
customer = card.customer
|
cus = card.customer
|
||||||
if customer is None:
|
if cus is None:
|
||||||
customer = Customer.objects.filter(
|
cus = Customer.objects.filter(
|
||||||
account_id__iexact=cleaned_data["code"]
|
account_id__iexact=cleaned_data["code"]
|
||||||
).first()
|
).first()
|
||||||
elif cleaned_data["id"]:
|
elif cleaned_data["id"] is not None:
|
||||||
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
|
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||||
|
if cus is None or not cus.can_buy:
|
||||||
if customer is None or not customer.can_buy:
|
|
||||||
raise forms.ValidationError(_("User not found"))
|
raise forms.ValidationError(_("User not found"))
|
||||||
cleaned_data["user_id"] = customer.user.id
|
cleaned_data["user_id"] = cus.user.id
|
||||||
cleaned_data["user"] = customer.user
|
cleaned_data["user"] = cus.user
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class RefillForm(forms.ModelForm):
|
class NFCCardForm(forms.Form):
|
||||||
allowed_refilling_methods = ["CASH", "CARD"]
|
student_card_uid = forms.CharField(
|
||||||
|
max_length=StudentCard.UID_SIZE,
|
||||||
|
required=False,
|
||||||
|
widget=NFCTextInput,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RefillForm(forms.ModelForm):
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
amount = forms.FloatField(
|
amount = forms.FloatField(
|
||||||
@ -125,21 +132,6 @@ class RefillForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Refilling
|
model = Refilling
|
||||||
fields = ["amount", "payment_method", "bank"]
|
fields = ["amount", "payment_method", "bank"]
|
||||||
widgets = {"payment_method": forms.RadioSelect}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.fields["payment_method"].choices = (
|
|
||||||
method
|
|
||||||
for method in self.fields["payment_method"].choices
|
|
||||||
if method[0] in self.allowed_refilling_methods
|
|
||||||
)
|
|
||||||
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
|
|
||||||
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
|
|
||||||
|
|
||||||
if "CHECK" not in self.allowed_refilling_methods:
|
|
||||||
del self.fields["bank"]
|
|
||||||
|
|
||||||
|
|
||||||
class CounterEditForm(forms.ModelForm):
|
class CounterEditForm(forms.ModelForm):
|
||||||
@ -154,9 +146,6 @@ class CounterEditForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class ProductEditForm(forms.ModelForm):
|
class ProductEditForm(forms.ModelForm):
|
||||||
error_css_class = "error"
|
|
||||||
required_css_class = "required"
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
fields = [
|
fields = [
|
||||||
@ -164,6 +153,7 @@ class ProductEditForm(forms.ModelForm):
|
|||||||
"description",
|
"description",
|
||||||
"product_type",
|
"product_type",
|
||||||
"code",
|
"code",
|
||||||
|
"parent_product",
|
||||||
"buying_groups",
|
"buying_groups",
|
||||||
"purchase_price",
|
"purchase_price",
|
||||||
"selling_price",
|
"selling_price",
|
||||||
@ -174,13 +164,8 @@ class ProductEditForm(forms.ModelForm):
|
|||||||
"tray",
|
"tray",
|
||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
help_texts = {
|
|
||||||
"description": _(
|
|
||||||
"Describe the product. If it's an event's click, "
|
|
||||||
"give some insights about it, like the date (including the year)."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
widgets = {
|
widgets = {
|
||||||
|
"parent_product": AutoCompleteSelectMultipleProduct,
|
||||||
"product_type": AutoCompleteSelect,
|
"product_type": AutoCompleteSelect,
|
||||||
"buying_groups": AutoCompleteSelectMultipleGroup,
|
"buying_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"club": AutoCompleteSelectClub,
|
"club": AutoCompleteSelectClub,
|
||||||
|
@ -55,9 +55,7 @@ class Command(BaseCommand):
|
|||||||
customer__user__in=reactivated_users
|
customer__user__in=reactivated_users
|
||||||
).delete()
|
).delete()
|
||||||
self._dump_accounts({u.customer for u in users_to_dump})
|
self._dump_accounts({u.customer for u in users_to_dump})
|
||||||
self.stdout.write("Accounts dumped")
|
self._send_mails(users_to_dump)
|
||||||
nb_successful_mails = self._send_mails(users_to_dump)
|
|
||||||
self.stdout.write(f"{nb_successful_mails} were successfuly sent.")
|
|
||||||
self.stdout.write("Finished !")
|
self.stdout.write("Finished !")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -105,14 +103,13 @@ class Command(BaseCommand):
|
|||||||
if len(pending_dumps) != len(customer_ids):
|
if len(pending_dumps) != len(customer_ids):
|
||||||
raise ValueError("One or more accounts were not engaged in a dump process")
|
raise ValueError("One or more accounts were not engaged in a dump process")
|
||||||
counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
|
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(
|
sales = Selling.objects.bulk_create(
|
||||||
[
|
[
|
||||||
Selling(
|
Selling(
|
||||||
label="Vidange compte inactif",
|
label="Vidange compte inactif",
|
||||||
club=counter.club,
|
club=counter.club,
|
||||||
counter=counter,
|
counter=counter,
|
||||||
seller=seller,
|
seller=None,
|
||||||
product=None,
|
product=None,
|
||||||
customer=account,
|
customer=account,
|
||||||
quantity=1,
|
quantity=1,
|
||||||
@ -127,7 +124,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# dumps and sales are linked to the same customers
|
# dumps and sales are linked to the same customers
|
||||||
# and or both ordered with the same key, so zipping them is valid
|
# and or both ordered with the same key, so zipping them is valid
|
||||||
for dump, sale in zip(pending_dumps, sales, strict=False):
|
for dump, sale in zip(pending_dumps, sales):
|
||||||
dump.dump_operation = sale
|
dump.dump_operation = sale
|
||||||
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
|
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
|
||||||
|
|
||||||
@ -137,12 +134,8 @@ class Command(BaseCommand):
|
|||||||
Customer.objects.filter(pk__in=customer_ids).update(amount=0)
|
Customer.objects.filter(pk__in=customer_ids).update(amount=0)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _send_mails(users: Iterable[User]) -> int:
|
def _send_mails(users: Iterable[User]):
|
||||||
"""Send the mails informing users that their account has been dumped.
|
"""Send the mails informing users that their account has been dumped."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
The number of emails successfully sent.
|
|
||||||
"""
|
|
||||||
mails = [
|
mails = [
|
||||||
(
|
(
|
||||||
_("Your AE account has been emptied"),
|
_("Your AE account has been emptied"),
|
||||||
@ -152,4 +145,4 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
for user in users
|
for user in users
|
||||||
]
|
]
|
||||||
return send_mass_mail(mails, fail_silently=True)
|
send_mass_mail(mails)
|
||||||
|
@ -1,6 +1,38 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
from counter.models import Counter, Customer, Product, Selling
|
||||||
|
|
||||||
|
|
||||||
|
def balance_ecocups(apps, schema_editor):
|
||||||
|
for customer in Customer.objects.all():
|
||||||
|
customer.recorded_products = 0
|
||||||
|
for selling in customer.buyings.filter(
|
||||||
|
product__id__in=[settings.SITH_ECOCUP_CONS, settings.SITH_ECOCUP_DECO]
|
||||||
|
).all():
|
||||||
|
if selling.product.is_record_product:
|
||||||
|
customer.recorded_products += selling.quantity
|
||||||
|
elif selling.product.is_unrecord_product:
|
||||||
|
customer.recorded_products -= selling.quantity
|
||||||
|
if customer.recorded_products < -settings.SITH_ECOCUP_LIMIT:
|
||||||
|
qt = -(customer.recorded_products + settings.SITH_ECOCUP_LIMIT)
|
||||||
|
cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
|
||||||
|
Selling(
|
||||||
|
label=_("Ecocup regularization"),
|
||||||
|
product=cons,
|
||||||
|
unit_price=cons.selling_price,
|
||||||
|
club=cons.club,
|
||||||
|
counter=Counter.objects.filter(name="Foyer").first(),
|
||||||
|
quantity=qt,
|
||||||
|
seller=User.objects.get(id=0),
|
||||||
|
customer=customer,
|
||||||
|
).save(allow_negative=True)
|
||||||
|
customer.recorded_products += qt
|
||||||
|
customer.save()
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -12,4 +44,5 @@ class Migration(migrations.Migration):
|
|||||||
name="recorded_products",
|
name="recorded_products",
|
||||||
field=models.IntegerField(verbose_name="recorded items", default=0),
|
field=models.IntegerField(verbose_name="recorded items", default=0),
|
||||||
),
|
),
|
||||||
|
migrations.RunPython(balance_ecocups),
|
||||||
]
|
]
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,53 +0,0 @@
|
|||||||
# 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",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,22 +0,0 @@
|
|||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,62 +0,0 @@
|
|||||||
# 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"),
|
|
||||||
]
|
|
@ -1,17 +0,0 @@
|
|||||||
# 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"),
|
|
||||||
),
|
|
||||||
]
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user