11 Commits

Author SHA1 Message Date
dae5cb06e7 fix rebase 2024-11-27 15:40:25 +01:00
113828f9b6 refactor and corrections for PR 2024-11-27 15:00:10 +01:00
203b5d88ac Fix missing current permanency display, delegate slot label config to locales 2024-11-27 15:00:07 +01:00
9206fed4ce Implemented locales + previous weeks in time graph 2024-11-27 15:00:05 +01:00
f133bac921 CSS Fix 2024-11-27 15:00:05 +01:00
1bce7e055f Round all perms to the quarter 2024-11-27 15:00:05 +01:00
ee19dc01f6 Global code cleanup 2024-11-27 15:00:02 +01:00
09dbda87bc Activity TimeGrid WIP 2024-11-27 14:58:30 +01:00
a44e8a68cb export graph to html function 2024-11-27 14:58:27 +01:00
71d155613f functionnal api 2024-11-27 14:58:24 +01:00
e30a6e8e6e api attempt 2024-11-27 14:57:33 +01:00
275 changed files with 13997 additions and 20210 deletions

View File

@ -1,17 +0,0 @@
HTTPS=off
SITH_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
# comment the sqlite line and uncomment the postgres one to switch the dbms
DATABASE_URL=sqlite:///db.sqlite3
#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith
REDIS_PORT=7963
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
# Used to select which other services to run alongside
# manage.py, pytest and runserver
PROCFILE_STATIC=Procfile.static
PROCFILE_SERVICE=Procfile.service

14
.envrc
View File

@ -1,6 +1,14 @@
if [[ ! -d .venv ]]; then
log_error 'No .venv folder found. Use `uv sync` to create one first.'
if [[ ! -f pyproject.toml ]]; then
log_error 'No pyproject.toml found. Use `poetry new` or `poetry init` to create one first.'
exit 2
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"

View 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

View File

@ -4,48 +4,48 @@ runs:
using: composite
steps:
- name: Install apt packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: gettext
version: 1.0 # increment to reset cache
- name: Install Redis
uses: shogo82148/actions-setup-redis@v1
with:
redis-version: "7.x"
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
- name: Set up python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
python-version: "3.12"
- name: Restore cached virtualenv
uses: actions/cache/restore@v4
- name: Load cached Poetry installation
id: cached-poetry
uses: actions/cache@v3
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
path: ~/.local
key: poetry-3 # increment to reset cache
- name: Install Poetry
if: steps.cached-poetry.outputs.cache-hit != 'true'
shell: bash
run: curl -sSL https://install.python-poetry.org | python3 -
- name: Check pyproject.toml syntax
shell: bash
run: poetry check
- name: Load cached dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pypoetry
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
${{ runner.os }}-poetry-
- name: Install dependencies
run: uv sync
run: poetry install --with docs,tests
shell: bash
- name: Install Xapian
run: uv run ./manage.py install_xapian
- name: Install xapian
run: poetry run ./manage.py install_xapian
shell: bash
- name: Save cached virtualenv
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
run: uv run ./manage.py compilemessages
run: poetry run ./manage.py compilemessages
shell: bash

10
.github/actions/setup_xapian/action.yml vendored Normal file
View 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

View File

@ -7,11 +7,6 @@ on:
branches: [master, taiste]
workflow_dispatch:
env:
SECRET_KEY: notTheRealOne
DATABASE_URL: sqlite:///db.sqlite3
CACHE_URL: redis://127.0.0.1:6379/0
jobs:
pre-commit:
name: Launch pre-commits checks (ruff)
@ -19,8 +14,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1
with:
extra_args: --all-files
@ -36,17 +29,16 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4
- uses: ./.github/actions/setup_project
env:
# To avoid race conditions on environment cache
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
- uses: ./.github/actions/setup_xapian
- uses: ./.github/actions/compile_messages
- 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
run: |
uv run coverage report
uv run coverage html
poetry run coverage report
poetry run coverage html
- name: Archive code coverage results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: coverage-report-${{ matrix.pytest-mark }}
name: coverage-report
path: coverage_report

View File

@ -37,29 +37,11 @@ jobs:
git fetch
git reset --hard origin/master
uv sync --group prod
poetry install --with prod --without docs,tests
npm install
uv run ./manage.py install_xapian
uv run ./manage.py migrate
uv run ./manage.py collectstatic --clear --noinput
uv run ./manage.py compilemessages
poetry run ./manage.py install_xapian
poetry run ./manage.py migrate
poetry run ./manage.py collectstatic --clear --noinput
poetry run ./manage.py compilemessages
sudo systemctl restart uwsgi
sentry:
runs-on: ubuntu-latest
environment: production
timeout-minutes: 30
needs: deployment
steps:
- uses: actions/checkout@v4
- name: Sentry Release
uses: getsentry/action-release@v1.7.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
with:
environment: production

View File

@ -18,4 +18,4 @@ jobs:
path: .cache
restore-keys: |
mkdocs-material-
- run: uv run mkdocs gh-deploy --force
- run: poetry run mkdocs gh-deploy --force

View File

@ -36,11 +36,11 @@ jobs:
git fetch
git reset --hard origin/taiste
uv sync --group prod
poetry install --with prod --without docs,tests
npm install
uv run ./manage.py install_xapian
uv run ./manage.py migrate
uv run ./manage.py collectstatic --clear --noinput
uv run ./manage.py compilemessages
poetry run ./manage.py install_xapian
poetry run ./manage.py migrate
poetry run ./manage.py collectstatic --clear --noinput
poetry run ./manage.py compilemessages
sudo systemctl restart uwsgi

10
.gitignore vendored
View File

@ -8,7 +8,7 @@ pyrightconfig.json
dist/
.vscode/
.idea/
.venv/
env/
doc/html
data/
galaxy/test_galaxy_state.json
@ -18,14 +18,6 @@ sith/search_indexes/
.coverage
coverage_report/
node_modules/
.env
*.pid
# compiled documentation
site/
### Redis ###
# Ignore redis binary dump (dump.rdb) files
*.rdb

1
.npmrc
View File

@ -1 +0,0 @@
@jsr:registry=https://npm.jsr.io

View File

@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.8.3
rev: v0.6.9
hooks:
- id: ruff # just check the code, and print the errors
- id: ruff # actually fix the fixable errors, but print nothing
@ -12,9 +12,9 @@ repos:
rev: "v0.1.0" # Use the sha / tag you want to point at
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"]
additional_dependencies: ["@biomejs/biome@1.9.3"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.7
rev: 3.0.6
hooks:
- id: djhtml
name: format templates

View File

@ -1 +0,0 @@
3.12

View File

@ -1 +0,0 @@
redis: redis-server --port $REDIS_PORT

View File

@ -1 +0,0 @@
bundler: npm run serve

View File

@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema
from core.auth.api_permissions import CanAccessLookup
from core.api_permissions import CanAccessLookup
@api_controller("/lookup", permissions=[CanAccessLookup])

View File

@ -216,7 +216,7 @@ class TestOperation(TestCase):
self.journal.operations.filter(target_label="Le fantome du jour").exists()
)
def test_operation_simple_accounting(self):
def test__operation_simple_accounting(self):
sat = SimplifiedAccountingType.objects.all().first()
response = self.client.post(
reverse("accounting:op_new", args=[self.journal.id]),
@ -237,14 +237,15 @@ class TestOperation(TestCase):
"done": False,
},
)
assert response.status_code != 403
assert self.journal.operations.filter(amount=23).exists()
self.assertFalse(response.status_code == 403)
self.assertTrue(self.journal.operations.filter(amount=23).exists())
response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id])
)
assert "<td>Le fantome de l&#39;aurore</td>" in str(response_get.content)
assert (
self.assertTrue(
"<td>Le fantome de l&#39;aurore</td>" in str(response_get.content)
)
self.assertTrue(
self.journal.operations.filter(amount=23)
.values("accounting_type")
.first()["accounting_type"]

View File

@ -17,7 +17,6 @@ import collections
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Sum
@ -45,15 +44,15 @@ from accounting.widgets.select import (
)
from club.models import Club
from club.widgets.select import AutoCompleteSelectClub
from core.auth.mixins import (
from core.models import User
from core.views import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
TabedViewMixin,
)
from core.models import User
from core.views.forms import SelectDate, SelectFile
from core.views.mixins import TabedViewMixin
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Counter, Product, Selling
@ -87,13 +86,12 @@ class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
template_name = "core/edit.jinja"
class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView):
class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView):
"""Create an accounting type (for the admins)."""
model = SimplifiedAccountingType
fields = ["label", "accounting_type"]
template_name = "core/create.jinja"
permission_required = "accounting.add_simplifiedaccountingtype"
# Accounting types
@ -115,13 +113,12 @@ class AccountingTypeEditView(CanViewMixin, UpdateView):
template_name = "core/edit.jinja"
class AccountingTypeCreateView(PermissionRequiredMixin, CreateView):
class AccountingTypeCreateView(CanCreateMixin, CreateView):
"""Create an accounting type (for the admins)."""
model = AccountingType
fields = ["code", "label", "movement_type"]
template_name = "core/create.jinja"
permission_required = "accounting.add_accountingtype"
# BankAccount views
@ -218,14 +215,17 @@ class JournalTabsMixin(TabedViewMixin):
return _("Journal")
def get_list_of_tabs(self):
return [
tab_list = []
tab_list.append(
{
"url": reverse(
"accounting:journal_details", kwargs={"j_id": self.object.id}
),
"slug": "journal",
"name": _("Journal"),
},
}
)
tab_list.append(
{
"url": reverse(
"accounting:journal_nature_statement",
@ -233,7 +233,9 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "nature_statement",
"name": _("Statement by nature"),
},
}
)
tab_list.append(
{
"url": reverse(
"accounting:journal_person_statement",
@ -241,7 +243,9 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "person_statement",
"name": _("Statement by person"),
},
}
)
tab_list.append(
{
"url": reverse(
"accounting:journal_accounting_statement",
@ -249,8 +253,9 @@ class JournalTabsMixin(TabedViewMixin):
),
"slug": "accounting_statement",
"name": _("Accounting statement"),
},
]
}
)
return tab_list
class JournalCreateView(CanCreateMixin, CreateView):

View File

@ -20,14 +20,6 @@ from club.models import Club, Membership
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
list_display = ("name", "unix_name", "parent", "is_active")
search_fields = ("name", "unix_name")
autocomplete_fields = (
"parent",
"board_group",
"members_group",
"home",
"page",
)
@admin.register(Membership)

View File

@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Club
from club.schemas import ClubSchema
from core.auth.api_permissions import CanAccessLookup
from core.api_permissions import CanAccessLookup
@api_controller("/club")

View File

@ -3,6 +3,19 @@ from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
from club.models import Club
from core.operations import PsqlRunOnly
def generate_club_pages(apps, schema_editor):
def recursive_generate_club_page(club):
club.make_page()
for child in Club.objects.filter(parent=club).all():
recursive_generate_club_page(child)
for club in Club.objects.filter(parent=None).all():
recursive_generate_club_page(club)
class Migration(migrations.Migration):
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
@ -35,4 +48,11 @@ class Migration(migrations.Migration):
null=True,
),
),
PsqlRunOnly(
"SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop
),
migrations.RunPython(generate_club_pages),
PsqlRunOnly(
migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE"
),
]

View File

@ -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
),
]

View File

@ -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",
),
),
]

View File

@ -23,7 +23,7 @@
#
from __future__ import annotations
from typing import Iterable, Self
from typing import Self
from django.conf import settings
from django.core import validators
@ -31,14 +31,14 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from core.models import Group, Notification, Page, SithFile, User
from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User
# Create your models here.
@ -79,6 +79,19 @@ class Club(models.Model):
_("short description"), max_length=1000, default="", blank=True, null=True
)
address = models.CharField(_("address"), max_length=254)
owner_group = models.ForeignKey(
Group,
related_name="owned_club",
default=get_default_owner_group,
on_delete=models.CASCADE,
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_club", blank=True
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_club", blank=True
)
home = models.OneToOneField(
SithFile,
related_name="home_of_club",
@ -90,12 +103,6 @@ class Club(models.Model):
page = models.OneToOneField(
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
)
members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT
)
board_group = models.OneToOneField(
Group, related_name="club_board", on_delete=models.PROTECT
)
class Meta:
ordering = ["name", "unix_name"]
@ -105,27 +112,23 @@ class Club(models.Model):
@transaction.atomic()
def save(self, *args, **kwargs):
creation = self._state.adding
if not creation:
db_club = Club.objects.get(id=self.id)
if self.unix_name != db_club.unix_name:
self.home.name = self.unix_name
self.home.save()
if self.name != db_club.name:
self.board_group.name = f"{self.name} - Bureau"
self.board_group.save()
self.members_group.name = f"{self.name} - Membres"
self.members_group.save()
if creation:
self.board_group = Group.objects.create(
name=f"{self.name} - Bureau", is_manually_manageable=False
)
self.members_group = Group.objects.create(
name=f"{self.name} - Membres", is_manually_manageable=False
)
old = Club.objects.filter(id=self.id).first()
creation = old is None
if not creation and old.unix_name != self.unix_name:
self._change_unixname(self.unix_name)
super().save(*args, **kwargs)
if creation:
board = MetaGroup(name=self.unix_name + settings.SITH_BOARD_SUFFIX)
board.save()
member = MetaGroup(name=self.unix_name + settings.SITH_MEMBER_SUFFIX)
member.save()
subscribers = Group.objects.filter(
name=settings.SITH_MAIN_MEMBERS_GROUP
).first()
self.make_home()
self.home.edit_groups.set([board])
self.home.view_groups.set([member, subscribers])
self.home.save()
self.make_page()
cache.set(f"sith_club_{self.unix_name}", self)
@ -133,8 +136,7 @@ class Club(models.Model):
return reverse("club:club_view", kwargs={"club_id": self.id})
@cached_property
def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club."""
def president(self):
return self.members.filter(
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
).first()
@ -152,18 +154,36 @@ class Club(models.Model):
def clean(self):
self.check_loop()
def make_home(self) -> None:
if self.home:
return
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
root = User.objects.filter(username="root").first()
if home_root and root:
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
home.save()
self.home = home
self.save()
def _change_unixname(self, old_name, new_name):
c = Club.objects.filter(unix_name=new_name).first()
if c is None:
# Update all the groups names
Group.objects.filter(name=old_name).update(name=new_name)
Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
name=new_name + settings.SITH_BOARD_SUFFIX
)
Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
name=new_name + settings.SITH_MEMBER_SUFFIX
)
def make_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()
if not self.page:
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.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
for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}")
cache.delete(f"sith_club_{self.unix_name}")
self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)
super().delete(*args, **kwargs)
def get_display_name(self) -> str:
def get_display_name(self):
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."""
if user.is_anonymous:
return False
return user.is_root or user.is_board_member
return user.is_board_member
def get_full_logo_url(self) -> str:
return f"https://{settings.SITH_URL}{self.logo.url}"
def get_full_logo_url(self):
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."""
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."""
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:
"""Return the current membership the given user.
@ -241,8 +262,9 @@ class Club(models.Model):
cache.set(f"membership_{self.id}_{user.id}", membership)
return membership
def has_rights_in_club(self, user: User) -> bool:
return user.is_in_group(pk=self.board_group_id)
def has_rights_in_club(self, user):
m = self.get_membership_for(user)
return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
class MembershipQuerySet(models.QuerySet):
@ -261,65 +283,42 @@ class MembershipQuerySet(models.QuerySet):
"""
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership.
def update(self, **kwargs):
"""Refresh the cache for the elements of the queryset.
Update the cache, when necessary, remove
users from club groups they are no more in
and add them in the club groups they should be in.
Besides that, does the same job as a regular update method.
Be aware that this adds three db queries :
one to retrieve the updated memberships,
one to perform group removal and one to perform
group attribution.
Be aware that this adds a db query to retrieve the updated objects
"""
nb_rows = super().update(**kwargs)
if nb_rows == 0:
# if no row was affected, no need to refresh the cache
return 0
if nb_rows > 0:
# if at least a row was affected, refresh the cache
for membership in self.all():
if membership.end_date is not None:
cache.set(
f"membership_{membership.club_id}_{membership.user_id}",
"not_member",
)
else:
cache.set(
f"membership_{membership.club_id}_{membership.user_id}",
membership,
)
cache_memberships = {}
memberships = set(self.select_related("club"))
# delete all User-Group relations and recreate the necessary ones
# It's more concise to write and more reliable
Membership._remove_club_groups(memberships)
Membership._add_club_groups(memberships)
for member in memberships:
cache_key = f"membership_{member.club_id}_{member.user_id}"
if member.end_date is None:
cache_memberships[cache_key] = member
else:
cache_memberships[cache_key] = "not_member"
cache.set_many(cache_memberships)
return nb_rows
def delete(self) -> tuple[int, dict[str, int]]:
def delete(self):
"""Work just like the default Django's delete() method,
but add a cache invalidation for the elements of the queryset
before the deletion,
and a removal of the user from the club groups.
before the deletion.
Be aware that this adds some db queries :
- 1 to retrieve the deleted elements in order to perform
post-delete operations.
As we can't know if a delete will affect rows or not,
this query will always happen
- 1 query to remove the users from the club groups.
If the delete operation affected no row,
this query won't happen.
Be aware that this adds a db query to retrieve the deleted element.
As this first query take place before the deletion operation,
it will be performed even if the deletion fails.
"""
memberships = set(self.all())
nb_rows, rows_counts = super().delete()
ids = list(self.values_list("club_id", "user_id"))
nb_rows, _ = super().delete()
if nb_rows > 0:
Membership._remove_club_groups(memberships)
cache.set_many(
{
f"membership_{m.club_id}_{m.user_id}": "not_member"
for m in memberships
}
)
return nb_rows, rows_counts
for club_id, user_id in ids:
cache.set(f"membership_{club_id}_{user_id}", "not_member")
class Membership(models.Model):
@ -362,13 +361,6 @@ class Membership(models.Model):
objects = MembershipQuerySet.as_manager()
class Meta:
constraints = [
models.CheckConstraint(
check=Q(end_date__gte=F("start_date")), name="end_after_start"
),
]
def __str__(self):
return (
f"{self.club.name} - {self.user.username} "
@ -378,14 +370,7 @@ class Membership(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# a save may either be an update or a creation
# and may result in either an ongoing or an ended membership.
# It could also be a retrogradation from the board to being a simple member.
# To avoid problems, the user is removed from the club groups beforehand ;
# he will be added back if necessary
self._remove_club_groups([self])
if self.end_date is None:
self._add_club_groups([self])
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
else:
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
@ -393,11 +378,11 @@ class Membership(models.Model):
def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id})
def is_owned_by(self, user: User) -> bool:
def is_owned_by(self, user):
"""Method to see if that object can be super edited by the given user."""
if user.is_anonymous:
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:
"""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
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod
def _remove_club_groups(
memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club groups.
For example, if a user is in the Troll club board,
he is in the board group and the members group of the Troll.
After calling this function, he will be in neither.
Returns:
The result of the deletion queryset.
Warnings:
If this function isn't used in combination
with an actual deletion of the memberships,
it will result in an inconsistent state,
where users will be in the clubs, without
having the associated rights.
"""
clubs = {m.club_id for m in memberships}
users = {m.user_id for m in memberships}
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
return User.groups.through.objects.filter(
Q(group__in=groups) & Q(user__in=users)
).delete()
@staticmethod
def _add_club_groups(
memberships: Iterable[Membership],
) -> list[User.groups.through]:
"""Add users of those memberships to the club groups.
For example, if a user just joined the Troll club board,
he will be added in both the members group and the board group
of the club.
Returns:
The created User-Group relations.
Warnings:
If this function isn't used in combination
with an actual update/creation of the memberships,
it will result in an inconsistent state,
where users will have the rights associated to the
club, without actually being part of it.
"""
# only active membership (i.e. `end_date=None`)
# grant the attribution of club groups.
memberships = [m for m in memberships if m.end_date is None]
if not memberships:
return []
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
# if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with
# a select_related than perform a distinct query
# to fetch each club.
ids = {m.id for m in memberships}
memberships = list(
Membership.objects.filter(id__in=ids).select_related("club")
)
club_groups = []
for membership in memberships:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.members_group_id,
)
)
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.board_group_id,
)
)
return User.groups.through.objects.bulk_create(
club_groups, ignore_conflicts=True
)
class Mailing(models.Model):
"""A Mailing list for a club.
@ -535,18 +438,19 @@ class Mailing(models.Model):
def save(self, *args, **kwargs):
if not self.is_moderated:
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
for user in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
):
Notification(
user=user,
url=reverse("com:mailing_admin"),
type="MAILING_MODERATION",
).save(*args, **kwargs)
if not user.notifications.filter(
type="MAILING_MODERATION", viewed=False
).exists():
Notification(
user=user,
url=reverse("com:mailing_admin"),
type="MAILING_MODERATION",
).save(*args, **kwargs)
super().save(*args, **kwargs)
def clean(self):

View File

@ -7,17 +7,3 @@ class ClubSchema(ModelSchema):
class Meta:
model = Club
fields = ["id", "name"]
class ClubProfileSchema(ModelSchema):
"""The infos needed to display a simple club profile."""
class Meta:
model = Club
fields = ["id", "name", "logo"]
url: str
@staticmethod
def resolve_url(obj: Club) -> str:
return obj.get_absolute_url()

View File

@ -21,7 +21,6 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import localdate, localtime, now
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, Mailing, Membership
@ -165,27 +164,6 @@ class TestMembershipQuerySet(TestClub):
assert new_mem != "not_member"
assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
membership = baker.make(Membership, end_date=None, user=user, role=5)
members_group = membership.club.members_group
board_group = membership.club.board_group
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(role=1) # from board to simple member
assert user.groups.contains(members_group)
assert not user.groups.contains(board_group)
user.memberships.update(role=5) # from member to board
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(end_date=localdate()) # end the membership
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.skia.memberships.get(club=self.club)
@ -204,19 +182,6 @@ class TestMembershipQuerySet(TestClub):
)
assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
club_groups = {
memberships[0].club.members_group,
memberships[1].club.members_group,
memberships[1].club.board_group,
}
assert set(user.groups.all()).issuperset(club_groups)
user.memberships.all().delete()
assert set(user.groups.all()).isdisjoint(club_groups)
class TestClubModel(TestClub):
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.role == role
assert membership.club.get_membership_for(user) == membership
assert user.is_in_group(pk=self.club.members_group_id)
assert user.is_in_group(pk=self.club.board_group_id)
member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX
assert user.is_in_group(name=member_group)
assert user.is_in_group(name=board_group)
def assert_membership_ended_today(self, user: User):
"""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 membership == new_mem
def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups."""
user = baker.make(User)
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
assert user.groups.contains(self.club.members_group)
assert user.groups.contains(self.club.board_group)
user.memberships.update(end_date=localdate())
assert not user.groups.contains(self.club.members_group)
assert not user.groups.contains(self.club.board_group)
def test_delete_remove_from_meta_group(self):
"""Test that when a club is deleted, all its members are removed from the
associated metagroup.
"""
memberships = self.club.members.select_related("user")
users = [membership.user for membership in memberships]
meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
def test_add_to_club_group(self):
"""Test that when a membership begins, the user is added to the club group."""
assert not self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
self.club.delete()
for user in users:
assert not user.is_in_group(name=meta_group)
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=3
)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
membership.role = 1
membership.save()
assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
def test_add_to_meta_group(self):
"""Test that when a membership begins, the user is added to the meta group."""
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
assert not self.subscriber.is_in_group(name=group_members)
assert not self.subscriber.is_in_group(name=board_members)
Membership.objects.create(club=self.club, user=self.subscriber, role=3)
assert self.subscriber.is_in_group(name=group_members)
assert self.subscriber.is_in_group(name=board_members)
def test_remove_from_meta_group(self):
"""Test that when a membership ends, the user is removed from meta group."""
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
assert self.comptable.is_in_group(name=group_members)
assert self.comptable.is_in_group(name=board_members)
self.comptable.memberships.update(end_date=localtime(now()))
assert not self.comptable.is_in_group(name=group_members)
assert not self.comptable.is_in_group(name=board_members)
def test_club_owner(self):
"""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()
assert self.club.is_owned_by(self.sli)
def test_change_club_name(self):
"""Test that changing the club name doesn't break things."""
members_group = self.club.members_group
board_group = self.club.board_group
initial_members = set(members_group.users.values_list("id", flat=True))
initial_board = set(board_group.users.values_list("id", flat=True))
self.club.name = "something else"
self.club.save()
self.club.refresh_from_db()
# The names should have changed, but not the ids nor the group members
assert self.club.members_group.name == "something else - Membres"
assert self.club.board_group.name == "something else - Bureau"
assert self.club.members_group.id == members_group.id
assert self.club.board_group.id == board_group.id
new_members = set(self.club.members_group.users.values_list("id", flat=True))
new_board = set(self.club.board_group.users.values_list("id", flat=True))
assert new_members == initial_members
assert new_board == initial_board
class TestMailingForm(TestCase):
"""Perform validation tests for MailingForm."""

View File

@ -25,7 +25,6 @@
import csv
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import Sum
@ -50,15 +49,17 @@ from com.views import (
PosterEditBaseView,
PosterListBaseView,
)
from core.auth.mixins import (
from core.models import PageRev
from core.views import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
DetailFormView,
PageEditViewBase,
TabedViewMixin,
UserIsRootMixin,
)
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin
from counter.models import Selling
@ -70,13 +71,14 @@ class ClubTabsMixin(TabedViewMixin):
return self.object.get_display_name()
def get_list_of_tabs(self):
tab_list = [
tab_list = []
tab_list.append(
{
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
"slug": "infos",
"name": _("Infos"),
}
]
)
if self.request.user.can_view(self.object):
tab_list.append(
{
@ -256,7 +258,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["request_user"] = self.request.user
kwargs["club"] = self.object
kwargs["club"] = self.get_object()
kwargs["club_members"] = self.members
return kwargs
@ -273,9 +275,9 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
users = data.pop("users", [])
users_old = data.pop("users_old", [])
for user in users:
Membership(club=self.object, user=user, **data).save()
Membership(club=self.get_object(), user=user, **data).save()
for user in users_old:
membership = self.object.get_membership_for(user)
membership = self.get_object().get_membership_for(user)
membership.end_date = timezone.now()
membership.save()
return resp
@ -285,7 +287,9 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse_lazy("club:club_members", kwargs={"club_id": self.object.id})
return reverse_lazy(
"club:club_members", kwargs={"club_id": self.get_object().id}
)
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
@ -471,14 +475,13 @@ class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "props"
class ClubCreateView(PermissionRequiredMixin, CreateView):
class ClubCreateView(CanCreateMixin, CreateView):
"""Create a club (for the Sith admin)."""
model = Club
pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent"]
template_name = "core/edit.jinja"
permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, DetailView):
@ -510,13 +513,12 @@ class MembershipSetOldView(CanEditMixin, DetailView):
)
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
class MembershipDeleteView(UserIsRootMixin, DeleteView):
"""Delete a membership (for admins only)."""
model = Membership
pk_url_kwarg = "membership_id"
template_name = "core/delete_confirm.jinja"
permission_required = "club.delete_membership"
def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})

View File

@ -13,25 +13,17 @@
#
#
from django.contrib import admin
from django.contrib.admin import TabularInline
from haystack.admin import SearchModelAdmin
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail
class NewsDateInline(TabularInline):
model = NewsDate
extra = 0
from com.models import News, Poster, Screen, Sith, Weekmail
@admin.register(News)
class NewsAdmin(SearchModelAdmin):
list_display = ("title", "club", "author")
list_display = ("title", "type", "club", "author")
search_fields = ("title", "summary", "content")
autocomplete_fields = ("author", "moderator")
inlines = [NewsDateInline]
@admin.register(Poster)
class PosterAdmin(SearchModelAdmin):

View File

@ -1,104 +0,0 @@
from pathlib import Path
from typing import Literal
from django.conf import settings
from django.http import Http404, HttpResponse
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from com.calendar import IcsCalendar
from com.models import News, NewsDate
from com.schemas import NewsDateFilterSchema, NewsDateSchema
from core.auth.api_permissions import HasPerm
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 its 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())
@route.get(
"/unpublished.ics",
permissions=[IsAuthenticated],
url_name="calendar_unpublished",
)
def calendar_unpublished(self):
return HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user),
content_type="text/calendar",
)
@api_controller("/news")
class NewsController(ControllerBase):
@route.patch(
"/{int:news_id}/publish",
permissions=[HasPerm("com.moderate_news")],
url_name="moderate_news",
)
def publish_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id)
if not news.is_published:
news.is_published = True
news.moderator = self.context.request.user
news.save()
@route.patch(
"/{int:news_id}/unpublish",
permissions=[HasPerm("com.moderate_news")],
url_name="unpublish_news",
)
def unpublish_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id)
if news.is_published:
news.is_published = False
news.moderator = self.context.request.user
news.save()
@route.delete(
"/{int:news_id}",
permissions=[HasPerm("com.delete_news")],
url_name="delete_news",
)
def delete_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id)
news.delete()
@route.get(
"/date",
url_name="fetch_news_dates",
response=PaginatedResponseSchema[NewsDateSchema],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def fetch_news_dates(
self,
filters: Query[NewsDateFilterSchema],
text_format: Literal["md", "html"] = "md",
):
return filters.filter(
NewsDate.objects.viewable_by(self.context.request.user)
.order_by("start_date")
.select_related("news", "news__club")
)

View File

@ -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

View File

@ -1,94 +0,0 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import final
import requests
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.db.models import F, QuerySet
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
from core.models import User
@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 = requests.get(
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics"
)
if not calendar.ok:
return None
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._EXTERNAL_CALENDAR, "wb") as f:
_ = f.write(calendar.content)
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
# 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(
cls.ics_from_queryset(
NewsDate.objects.filter(
news__is_published=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
)
)
)
return cls._INTERNAL_CALENDAR
@classmethod
def get_unpublished(cls, user: User) -> bytes:
return cls.ics_from_queryset(
NewsDate.objects.viewable_by(user).filter(
news__is_published=False,
end_date__gte=timezone.now() - (relativedelta(months=6)),
),
)
@classmethod
def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
calendar = Calendar()
for news_date in queryset.annotate(news_title=F("news__title")):
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)
return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")

View File

@ -1,193 +0,0 @@
from datetime import date
from dateutil.relativedelta import relativedelta
from django import forms
from django.db.models import Exists, OuterRef
from django.forms import CheckboxInput
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import Club
from club.widgets.select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
class PosterForm(forms.ModelForm):
class Meta:
model = Poster
fields = [
"name",
"file",
"club",
"screens",
"date_begin",
"date_end",
"display_time",
]
widgets = {"screens": forms.CheckboxSelectMultiple}
help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
date_begin = forms.DateTimeField(
label=_("Start date"),
widget=SelectDateTime,
required=True,
initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
)
date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if self.user and not self.user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter(
id__in=self.user.clubs_with_rights
)
self.fields.pop("display_time")
class NewsDateForm(forms.ModelForm):
"""Form to select the dates of an event."""
required_css_class = "required"
class Meta:
model = NewsDate
fields = ["start_date", "end_date"]
widgets = {"start_date": SelectDateTime, "end_date": SelectDateTime}
is_weekly = forms.BooleanField(
label=_("Weekly event"),
help_text=_("Weekly events will occur each week for a specified timespan."),
widget=CheckboxInput(attrs={"class": "switch"}),
initial=False,
required=False,
)
occurrence_choices = [
*[(str(i), _("%d times") % i) for i in range(2, 7)],
("SEMESTER_END", _("Until the end of the semester")),
]
occurrences = forms.ChoiceField(
label=_("Occurrences"),
help_text=_("How much times should the event occur (including the first one)"),
choices=occurrence_choices,
initial="SEMESTER_END",
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.label_suffix = ""
@classmethod
def get_occurrences(cls, number: int) -> tuple[str, str] | None:
"""Find the occurrence choice corresponding to numeric number of occurrences."""
if number < 2:
# If only 0 or 1 date, there cannot be weekly events
return None
# occurrences have all a numeric value, except "SEMESTER_END"
str_num = str(number)
occurrences = next((c for c in cls.occurrence_choices if c[0] == str_num), None)
if occurrences:
return occurrences
return next((c for c in cls.occurrence_choices if c[0] == "SEMESTER_END"), None)
def save(self, commit: bool = True, *, news: News): # noqa FBT001
# the base save method contains some checks we want to run
# before doing our own logic
super().save(commit=False)
# delete existing dates before creating new ones
news.dates.all().delete()
if not self.cleaned_data.get("is_weekly"):
self.instance.news = news
return super().save(commit=commit)
dates: list[NewsDate] = [self.instance]
occurrences = self.cleaned_data.get("occurrences")
start = self.instance.start_date
end = self.instance.end_date
if occurrences[0].isdigit():
nb_occurrences = int(occurrences[0])
else: # to the end of the semester
start_date = date(start.year, start.month, start.day)
nb_occurrences = (get_end_of_semester(start_date) - start_date).days // 7
dates.extend(
[
NewsDate(
start_date=start + relativedelta(weeks=i),
end_date=end + relativedelta(weeks=i),
)
for i in range(1, nb_occurrences)
]
)
for d in dates:
d.news = news
if not commit:
return dates
return NewsDate.objects.bulk_create(dates)
class NewsForm(forms.ModelForm):
"""Form to create or edit news."""
error_css_class = "error"
required_css_class = "required"
class Meta:
model = News
fields = ["title", "club", "summary", "content"]
widgets = {
"author": forms.HiddenInput,
"summary": MarkdownInput,
"content": MarkdownInput,
}
auto_publish = forms.BooleanField(
label=_("Auto publication"),
widget=CheckboxInput(attrs={"class": "switch"}),
required=False,
)
def __init__(self, *args, author: User, date_form: NewsDateForm, **kwargs):
super().__init__(*args, **kwargs)
self.author = author
self.date_form = date_form
self.label_suffix = ""
# if the author is an admin, he/she can choose any club,
# otherwise, only clubs for which he/she is a board member can be selected
if author.is_root or author.is_com_admin:
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.all(), widget=AutoCompleteSelectClub
)
else:
active_memberships = author.memberships.board().ongoing()
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.filter(
Exists(active_memberships.filter(club=OuterRef("pk")))
)
)
def is_valid(self):
return super().is_valid() and self.date_form.is_valid()
def full_clean(self):
super().full_clean()
self.date_form.full_clean()
def save(self, commit: bool = True): # noqa FBT001
self.instance.author = self.author
if (self.author.is_com_admin or self.author.is_root) and (
self.cleaned_data.get("auto_publish") is True
):
self.instance.is_published = True
self.instance.moderator = self.author
else:
self.instance.is_published = False
created_news = super().save(commit=commit)
self.date_form.save(commit=commit, news=created_news)
return created_news

View File

@ -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",
),
),
]

View File

@ -1,61 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-06 21:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("com", "0007_alter_news_club_alter_news_content_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="news",
options={
"verbose_name": "news",
"permissions": [
("moderate_news", "Can moderate news"),
("view_unmoderated_news", "Can view non-moderated news"),
],
},
),
migrations.AlterModelOptions(
name="newsdate",
options={"verbose_name": "news date", "verbose_name_plural": "news dates"},
),
migrations.AlterModelOptions(
name="poster",
options={"permissions": [("moderate_poster", "Can moderate poster")]},
),
migrations.RemoveField(model_name="news", name="type"),
migrations.AlterField(
model_name="news",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_news",
to=settings.AUTH_USER_MODEL,
verbose_name="author",
),
),
migrations.AlterField(
model_name="newsdate",
name="end_date",
field=models.DateTimeField(verbose_name="end_date"),
),
migrations.AlterField(
model_name="newsdate",
name="start_date",
field=models.DateTimeField(verbose_name="start_date"),
),
migrations.AddConstraint(
model_name="newsdate",
constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))),
name="news_date_end_date_after_start_date",
),
),
]

View File

@ -1,16 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("com", "0008_alter_news_options_alter_newsdate_options_and_more")]
operations = [
migrations.RenameField(
model_name="news", old_name="is_moderated", new_name="is_published"
),
migrations.AlterField(
model_name="news",
name="is_published",
field=models.BooleanField(default=False, verbose_name="is published"),
),
]

View File

@ -17,17 +17,16 @@
# details.
#
# 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.
#
#
from typing import Self
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
from django.db import models, transaction
from django.db.models import F, Q
from django.db.models import Q
from django.shortcuts import render
from django.templatetags.static import static
from django.urls import reverse
@ -35,7 +34,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import Club
from core.models import Notification, Preferences, User
from core.models import Notification, Preferences, RealGroup, User
class Sith(models.Model):
@ -54,87 +53,57 @@ class Sith(models.Model):
return user.is_com_admin
class NewsQuerySet(models.QuerySet):
def moderated(self) -> Self:
return self.filter(is_published=True)
def viewable_by(self, user: User) -> Self:
"""Filter news that the given user can view.
If the user has the `com.view_unmoderated_news` permission,
all news are viewable.
Else the viewable news are those that are either moderated
or authored by the user.
"""
if user.has_perm("com.view_unmoderated_news"):
return self
q_filter = Q(is_published=True)
if user.is_authenticated:
q_filter |= Q(author_id=user.id)
return self.filter(q_filter)
NEWS_TYPES = [
("NOTICE", _("Notice")),
("EVENT", _("Event")),
("WEEKLY", _("Weekly")),
("CALL", _("Call")),
]
class News(models.Model):
"""News about club events."""
"""The news class."""
title = models.CharField(_("title"), max_length=64)
summary = models.TextField(
_("summary"),
help_text=_(
"A description of the event (what is the activity ? "
"is there an associated clic ? is there a inscription form ?)"
),
)
content = models.TextField(
_("content"),
blank=True,
default="",
help_text=_("A more detailed and exhaustive description of the event."),
summary = models.TextField(_("summary"))
content = models.TextField(_("content"))
type = models.CharField(
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
)
club = models.ForeignKey(
Club,
related_name="news",
verbose_name=_("club"),
on_delete=models.CASCADE,
help_text=_("The club which organizes the event."),
Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
)
author = models.ForeignKey(
User,
related_name="owned_news",
verbose_name=_("author"),
on_delete=models.PROTECT,
on_delete=models.CASCADE,
)
is_published = models.BooleanField(_("is published"), default=False)
is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_news",
verbose_name=_("moderator"),
null=True,
on_delete=models.SET_NULL,
on_delete=models.CASCADE,
)
objects = NewsQuerySet.as_manager()
class Meta:
verbose_name = _("news")
permissions = [
("moderate_news", "Can moderate news"),
("view_unmoderated_news", "Can view non-moderated news"),
]
def __str__(self):
return self.title
return "%s: %s" % (self.type, self.title)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.is_published:
return
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
):
Notification.objects.create(
user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
)
Notification(
user=u,
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
param="1",
).save()
def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id})
@ -147,51 +116,35 @@ class News(models.Model):
return False
return user.is_com_admin or user == self.author
def can_be_edited_by(self, user: User):
return user.is_authenticated and (
self.author_id == user.id or user.has_perm("com.change_news")
)
def can_be_edited_by(self, user):
return user.is_com_admin
def can_be_viewed_by(self, user: User):
return (
self.is_published
or user.has_perm("com.view_unmoderated_news")
or (user.is_authenticated and self.author_id == user.id)
)
def can_be_viewed_by(self, user):
return self.is_moderated or user.is_com_admin
def news_notification_callback(notif):
count = News.objects.filter(
dates__start_date__gt=timezone.now(), is_published=False
).count()
count = (
News.objects.filter(
Q(dates__start_date__gt=timezone.now(), is_moderated=False)
| Q(type="NOTICE", is_moderated=False)
)
.distinct()
.count()
)
if count:
notif.viewed = False
notif.param = str(count)
notif.param = "%s" % count
notif.date = timezone.now()
else:
notif.viewed = True
class NewsDateQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the event dates that the given user can view.
- If the can view non moderated news, he can view all news dates
- else, he can view the dates of news that are either
authored by him or moderated.
"""
if user.has_perm("com.view_unmoderated_news"):
return self
q_filter = Q(news__is_published=True)
if user.is_authenticated:
q_filter |= Q(news__author_id=user.id)
return self.filter(q_filter)
class NewsDate(models.Model):
"""A date associated with news.
"""A date class, useful for weekly events, or for events that just have no date.
A [News][] can have multiple dates, for example if it is a recurring event.
This class allows more flexibilty managing the dates related to a news, particularly when this news is weekly, since
we don't have to make copies
"""
news = models.ForeignKey(
@ -200,23 +153,11 @@ class NewsDate(models.Model):
verbose_name=_("news_date"),
on_delete=models.CASCADE,
)
start_date = models.DateTimeField(_("start_date"))
end_date = models.DateTimeField(_("end_date"))
objects = NewsDateQuerySet.as_manager()
class Meta:
verbose_name = _("news date")
verbose_name_plural = _("news dates")
constraints = [
models.CheckConstraint(
check=Q(end_date__gte=F("start_date")),
name="news_date_end_date_after_start_date",
)
]
start_date = models.DateTimeField(_("start_date"), null=True, blank=True)
end_date = models.DateTimeField(_("end_date"), null=True, blank=True)
def __str__(self):
return f"{self.news.title}: {self.start_date} - {self.end_date}"
return "%s: %s - %s" % (self.news.title, self.start_date, self.end_date)
class Weekmail(models.Model):
@ -375,22 +316,21 @@ class Poster(models.Model):
on_delete=models.CASCADE,
)
class Meta:
permissions = [("moderate_poster", "Can moderate poster")]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.is_moderated:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
):
Notification.objects.create(
user=user,
Notification(
user=u,
url=reverse("com:poster_moderate_list"),
type="POSTER_MODERATION",
)
).save()
return super().save(*args, **kwargs)
def clean(self, *args, **kwargs):

View File

@ -1,58 +0,0 @@
from datetime import datetime
from ninja import FilterSchema, ModelSchema
from ninja_extra import service_resolver
from ninja_extra.controllers import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema
from com.models import News, NewsDate
from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="end_date__lt")
after: datetime | None = Field(None, q="start_date__gt")
club_id: int | None = Field(None, q="news__club_id")
news_id: int | None = None
is_published: bool | None = Field(None, q="news__is_published")
title: str | None = Field(None, q="news__title__icontains")
class NewsSchema(ModelSchema):
class Meta:
model = News
fields = ["id", "title", "summary", "is_published"]
club: ClubProfileSchema
url: str
@staticmethod
def resolve_summary(obj: News) -> str:
# if this is returned from a route that allows the
# user to choose the text format (md or html)
# and the user chose "html", convert the markdown to html
context: RouteContext = service_resolver(RouteContext)
if context.kwargs.get("text_format", "") == "html":
return markdown(obj.summary)
return obj.summary
@staticmethod
def resolve_url(obj: News) -> str:
return obj.get_absolute_url()
class NewsDateSchema(ModelSchema):
"""Basic infos about an event occurrence.
Warning:
This uses [NewsSchema][], which itself
uses [ClubProfileSchema][club.schemas.ClubProfileSchema].
Don't forget the appropriated `select_related`.
"""
class Meta:
model = NewsDate
fields = ["id", "start_date", "end_date"]
news: NewsSchema

View File

@ -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()

View File

@ -1,342 +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,
calendarCalendarUnpublished,
newsDeleteNews,
newsPublishNews,
newsUnpublishNews,
} from "#openapi";
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete"];
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") {
this.locale = newValue;
}
if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
}
}
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);
}
getNewsId(event: EventImpl) {
return Number.parseInt(
event.url
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
);
}
async refreshEvents() {
this.click(); // Remove focus from popup
// We can't just refresh events because some ics files are in
// local browser cache (especially internal.ics)
// To invalidate the cache, we need to remove the source and add it again
this.calendar.removeAllEventSources();
for (const source of await this.getEventSources()) {
this.calendar.addEventSource(source);
}
this.calendar.refetchEvents();
}
async publishNews(id: number) {
await newsPublishNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-publish", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async unpublishNews(id: number) {
await newsUnpublishNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-unpublish", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async deleteNews(id: number) {
await newsDeleteNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-delete", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async getEventSources() {
const cacheInvalidate = `?invalidate=${Date.now()}`;
return [
{
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
format: "ics",
className: "internal",
},
{
url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
format: "ics",
className: "external",
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
format: "ics",
color: "red",
className: "unpublished",
},
];
}
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");
};
const makePopupTools = (event: EventImpl) => {
if (event.source.internalEventSource.ui.classNames.includes("external")) {
return null;
}
if (!(this.canDelete || this.canModerate)) {
return null;
}
const newsId = this.getNewsId(event);
const div = document.createElement("div");
if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-check"></i>${gettext("Publish")}`;
button.setAttribute("class", "btn btn-green");
button.onclick = () => {
this.publishNews(newsId);
};
div.appendChild(button);
} else {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-times"></i>${gettext("Unpublish")}`;
button.setAttribute("class", "btn btn-orange");
button.onclick = () => {
this.unpublishNews(newsId);
};
div.appendChild(button);
}
}
if (this.canDelete) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-trash-can"></i>${gettext("Delete")}`;
button.setAttribute("class", "btn btn-red");
button.onclick = () => {
this.deleteNews(newsId);
};
div.appendChild(button);
}
return makePopupInfo(div, "fa-solid fa-toolbox");
};
// 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);
}
const tools = makePopupTools(event.event);
if (tools !== null) {
popupContainer.appendChild(tools);
}
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: await this.getEventSources(),
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();
}
});
}
}

View File

@ -1,81 +0,0 @@
import { exportToHtml } from "#core:utils/globals";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript
const AlertState = {
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
PENDING: 1,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
PUBLISHED: 2,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DELETED: 3,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DISPLAYED: 4, // When published at page generation
};
exportToHtml("AlertState", AlertState);
document.addEventListener("alpine:init", () => {
Alpine.data("moderationAlert", (newsId: number) => ({
state: AlertState.PENDING,
newsId: newsId as number,
loading: false,
async publishNews() {
this.loading = true;
// biome-ignore lint/style/useNamingConvention: api is snake case
await newsPublishNews({ path: { news_id: this.newsId } });
this.state = AlertState.PUBLISHED;
this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
this.loading = false;
},
async deleteNews() {
this.loading = true;
// biome-ignore lint/style/useNamingConvention: api is snake case
await newsDeleteNews({ path: { news_id: this.newsId } });
this.state = AlertState.DELETED;
this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
this.loading = false;
},
/**
* Event receiver for when news dates are moderated.
*
* If the moderated date is linked to the same news
* as the one this moderation alert is attached to,
* then set the alert state to the same as the moderated one.
*/
dispatchModeration(event: CustomEvent) {
if (event.detail.newsId === this.newsId) {
this.state = event.detail.state;
}
},
/**
* Query the server to know the number of news dates that would be moderated
* if this one is moderated.
*/
async nbToPublish(): Promise<number> {
// What we want here is the count attribute of the response.
// We don't care about the actual results,
// so we ask for the minimum page size possible.
const response = await newsFetchNewsDates({
// biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 },
});
return response.data.count;
},
weeklyEventWarningMessage(nbEvents: number): string {
return interpolate(
gettext(
"This event will take place every week for %s weeks. " +
"If you publish or delete this event, " +
"it will also be published (or deleted) for the following weeks.",
),
[nbEvents],
);
},
}));
});

View File

@ -1,67 +0,0 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date;
}
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date) => ({
startDate: startDate,
currentPage: 1,
pageSize: 6,
hasNext: true,
loading: false,
newsDates: [] as NewsDateSchema[],
async loadMore() {
this.loading = true;
const response = await newsFetchNewsDates({
query: {
after: this.startDate.toISOString(),
// biome-ignore lint/style/useNamingConvention: api is snake_case
text_format: "html",
page: this.currentPage,
// biome-ignore lint/style/useNamingConvention: api is snake_case
page_size: this.pageSize,
},
});
if (response.response.status === 404) {
this.hasNext = false;
} else if (response.data.next === null) {
this.newsDates.push(...response.data.results);
this.hasNext = false;
} else {
this.newsDates.push(...response.data.results);
this.currentPage += 1;
}
this.loading = false;
},
groupedDates(): Record<string, NewsDateSchema[]> {
return this.newsDates
.map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({
...date,
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: new Date(date.start_date),
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: new Date(date.end_date),
}),
)
.reduce(
(acc: Record<string, ParsedNewsDateSchema[]>, date: ParsedNewsDateSchema) => {
const key = date.start_date.toDateString();
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(date);
return acc;
},
{},
);
},
}));
});

View File

@ -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: 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;
}
}
}

View File

@ -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;
}
}

View File

@ -1,222 +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;
}
.feed {
float: right;
color: #f26522;
}
}
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
}
}
/* UPCOMING EVENTS */
#upcoming-events {
max-height: 600px;
overflow-y: scroll;
#load-more-news-button {
text-align: center;
button {
width: 150px;
}
}
}
/* 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: flex;
flex-direction: column;
gap: .5em;
padding: 1em;
header {
img {
height: 75px;
}
.header_content {
display: flex;
flex-direction: column;
justify-content: center;
gap: .2rem;
h4 {
margin-top: 0;
text-transform: uppercase;
}
}
}
}
}
}
/* END EVENTS TODAY AND NEXT FEW DAYS */
.news_empty {
margin-left: 1em;
}
.news_date {
color: grey;
}
}

View File

@ -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%;
}
}
}
}

View File

@ -1,127 +0,0 @@
{% macro news_moderation_alert(news, user, alpineState = None) %}
{# An alert to display on top of unpublished news,
with actions to either publish or delete them.
The current state of the alert is accessible through
the given `alpineState` variable.
This state is a `AlertState`, as defined in `moderation-alert-index.ts`
This comes in three flavours :
- You can pass the `News` object itself to the macro.
In this case, if `request.user` can publish news,
it will perform an additional db query to know if it is a recurring event.
- You can also give only the news id.
In this case, a server request will be issued to know
if it is a recurring event.
- Finally, you can pass the name of an alpine variable, which value is the id.
In this case, a server request will be issued to know
if it is a recurring event.
Example with full `News` object :
```jinja
<div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news, user, "state") }}
</div>
```
With an id :
```jinja
<div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news.id, user, "state") }}
</div>
```
An with an alpine variable
```jinja
<div x-data="{state: AlertState.PENDING, newsId: {{ news.id }}">
{{ news_moderation_alert("newsId", user, "state") }}
</div>
```
Args:
news: (News | int | string)
Either the `News` object to which this alert is related,
or its id, or the name of an Alpine which value is its id
user: The request.user
alpineState: An alpine variable name
Warning:
If you use this macro, you must also include `moderation-alert-index.ts`
in your template.
#}
<div
{% if news is integer or news is string %}
x-data="moderationAlert({{ news }})"
{% else %}
x-data="moderationAlert({{ news.id }})"
{% endif %}
{# the news-moderated is received when a moderation alert is deleted or moderated #}
@news-moderated.window="dispatchModeration($event)"
{% if alpineState %}
x-model="{{ alpineState }}"
x-modelable="state"
{% endif %}
>
<template x-if="state === AlertState.PENDING">
<div class="alert alert-yellow">
<div class="alert-main">
<strong>{% trans %}Waiting publication{% endtrans %}</strong>
<p>
{% trans trimmed %}
This news isn't published and is visible
only by its author and the communication admins.
{% endtrans %}
</p>
<p>
{% trans trimmed %}
It will stay hidden for other users until it has been published.
{% endtrans %}
</p>
{% if user.has_perm("com.moderate_news") %}
{# This is an additional query for each non-moderated news,
but it will be executed only for admin users, and only one time
(if they do their job and moderated news as soon as they see them),
so it's still reasonable #}
<div
{% if news is integer or news is string %}
x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToPublish()"
{% else %}
x-data="{ nbEvents: {{ news.dates.count() }} }"
{% endif %}
>
<template x-if="nbEvents > 1">
<div>
<br>
<strong>{% trans %}Weekly event{% endtrans %}</strong>
<p x-text="weeklyEventWarningMessage(nbEvents)"></p>
</div>
</template>
</div>
{% endif %}
</div>
{% if user.has_perm("com.moderate_news") %}
<span class="alert-aside" :aria-busy="loading">
<button class="btn btn-green" @click="publishNews()" :disabled="loading">
<i class="fa fa-check"></i> {% trans %}Publish{% endtrans %}
</button>
{% endif %}
{% if user.has_perm("com.delete_news") %}
<button class="btn btn-red" @click="deleteNews()" :disabled="loading">
<i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
</button>
</span>
{% endif %}
</div>
</template>
<template x-if="state === AlertState.PUBLISHED">
<div class="alert alert-green">
{% trans %}News published{% endtrans %}
</div>
</template>
<template x-if="state === AlertState.DELETED">
<div class="alert alert-red">
{% trans %}News deleted{% endtrans %}
</div>
</template>
</div>
{% endmacro %}

View File

@ -10,13 +10,78 @@
<p><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></p>
<hr />
<h4>{% trans %}Notices{% endtrans %}</h4>
{% set notices = object_list.filter(type="NOTICE").distinct().order_by('id') %}
<h5>{% trans %}Displayed notices{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Moderator{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in notices.filter(is_moderated=True) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h5>{% trans %}Notices to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in notices.filter(is_moderated=False) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr />
<h4>{% trans %}Weeklies{% endtrans %}</h4>
{% set weeklies = object_list.filter(dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
{% set weeklies = object_list.filter(type="WEEKLY", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
<h5>{% trans %}Displayed weeklies{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
@ -27,8 +92,9 @@
</tr>
</thead>
<tbody>
{% for news in weeklies.filter(is_published=True) %}
{% for news in weeklies.filter(is_moderated=True) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
@ -47,7 +113,7 @@
</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
@ -58,6 +124,7 @@
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
@ -67,8 +134,9 @@
</tr>
</thead>
<tbody>
{% for news in weeklies.filter(is_published=False) %}
{% for news in weeklies.filter(is_moderated=False) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
@ -86,20 +154,22 @@
</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr />
<h4>{% trans %}Events{% endtrans %}</h4>
{% set events = object_list.filter(dates__end_date__gte=timezone.now()).order_by('id') %}
<h5>{% trans %}Displayed events{% endtrans %}</h5>
<h4>{% trans %}Calls{% endtrans %}</h4>
{% set calls = object_list.filter(type="CALL", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
<h5>{% trans %}Displayed calls{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
@ -111,30 +181,32 @@
</tr>
</thead>
<tbody>
{% for news in events.filter(is_published=True) %}
{% for news in calls.filter(is_moderated=True) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td>
<td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h5>{% trans %}Events to moderate{% endtrans %}</h5>
<h5>{% trans %}Calls to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
@ -145,19 +217,96 @@
</tr>
</thead>
<tbody>
{% for news in events.filter(is_published=False) %}
{% for news in calls.filter(is_moderated=False) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr />
<h4>{% trans %}Events{% endtrans %}</h4>
{% set events = object_list.filter(type="EVENT", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
<h5>{% trans %}Displayed events{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Moderator{% endtrans %}</td>
<td>{% trans %}Start{% endtrans %}</td>
<td>{% trans %}End{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in events.filter(is_moderated=True) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h5>{% trans %}Events to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Start{% endtrans %}</td>
<td>{% trans %}End{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in events.filter(is_moderated=False) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>

View File

@ -1,6 +1,5 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
{% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}
{% trans %}News{% endtrans %} -
@ -12,54 +11,39 @@
{{ gen_news_metatags(news) }}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
{% endblock %}
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
{% endblock %}
{% block content %}
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
<div x-data="{newsState: AlertState.PENDING}">
{% if not news.is_published %}
{{ news_moderation_alert(news, user, "newsState") }}
{% endif %}
<article id="news_details" x-show="newsState !== AlertState.DELETED">
<div class="club_logo">
<img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
<section id="news_details">
<div class="club_logo">
<img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
</div>
<h4>{{ news.title }}</h4>
<p class="date">
<span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</p>
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
{{ facebook_share(news) }}
{{ tweet(news) }}
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}
<p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
{% elif user.is_com_admin %}
<p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a></p>
{% endif %}
{% if user.can_edit(news) %}
<p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
{% endif %}
</div>
<h4>{{ news.title }}</h4>
<p class="date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p>
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
{{ facebook_share(news) }}
{{ tweet(news) }}
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}
<p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
{% elif user.is_com_admin %}
<p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a></p>
{% endif %}
{% if user.can_edit(news) %}
<p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
{% endif %}
</div>
</div>
</article>
</div>
</div>
</section>
{% endblock %}

View File

@ -10,6 +10,21 @@
{% endblock %}
{% block content %}
{% if 'preview' in request.POST.keys() %}
<section class="news_event">
<h4>{{ form.instance.title }}</h4>
<p class="date">
<span>{{ form.instance.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ form.instance.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ form.instance.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ form.instance.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</p>
<p><a href="#">{{ form.instance.club or "Club" }}</a></p>
<div>{{ form.instance.summary|markdown }}</div>
<div>{{ form.instance.content|markdown }}</div>
<p>{% trans %}Author: {% endtrans %} {{ user_profile_link(form.instance.author) }}</p>
</section>
{% endif %}
{% if object %}
<h2>{% trans %}Edit news{% endtrans %}</h2>
{% else %}
@ -18,73 +33,55 @@
<form action="" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
<fieldset>
{{ form.title.errors }}
{{ form.title.label_tag() }}
{{ form.title }}
</fieldset>
<fieldset>
{{ form.club.errors }}
{{ form.club.label_tag() }}
<span class="helptext">{{ form.club.help_text }}</span>
{{ form.club }}
</fieldset>
{{ form.date_form.non_field_errors() }}
<div
class="row gap-2x"
x-data="{startDate: '{{ form.date_form.start_date.value() }}'}"
>
{# startDate is used to dynamically ensure end_date >= start_date,
whatever the value of start_date #}
<fieldset>
{{ form.date_form.start_date.errors }}
{{ form.date_form.start_date.label_tag() }}
<span class="helptext">{{ form.date_form.start_date.help_text }}</span>
{{ form.date_form.start_date|add_attr("x-model=startDate") }}
</fieldset>
<fieldset>
{{ form.date_form.end_date.errors }}
{{ form.date_form.end_date.label_tag() }}
<span class="helptext">{{ form.date_form.end_date.help_text }}</span>
{{ form.date_form.end_date|add_attr(":min=startDate") }}
</fieldset>
</div>
{# lower to convert True and False to true and false #}
<div x-data="{isWeekly: {{ form.date_form.is_weekly.value()|lower }}}">
<fieldset>
<div class="row gap">
{{ form.date_form.is_weekly|add_attr("x-model=isWeekly") }}
<div>
{{ form.date_form.is_weekly.label_tag() }}
<span class="helptext">{{ form.date_form.is_weekly.help_text }}</span>
</div>
</div>
</fieldset>
<fieldset x-show="isWeekly" x-transition x-cloak>
{{ form.date_form.occurrences.label_tag() }}
<span class="helptext">{{ form.date_form.occurrences.help_text }}</span>
{{ form.date_form.occurrences }}
</fieldset>
</div>
<fieldset>
{{ form.summary.errors }}
{{ form.summary.label_tag() }}
<span class="helptext">{{ form.summary.help_text }}</span>
{{ form.summary }}
</fieldset>
<fieldset>
{{ form.content.errors }}
{{ form.content.label_tag() }}
<span class="helptext">{{ form.content.help_text }}</span>
{{ form.content }}
</fieldset>
{% if user.is_root or user.is_com_admin %}
<fieldset>
{{ form.auto_publish.errors }}
{{ form.auto_publish }}
{{ form.auto_publish.label_tag() }}
</fieldset>
{{ form.author }}
<p>{{ form.type.errors }}<label for="{{ form.type.name }}">{{ form.type.label }}</label>
<ul>
<li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
<li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
<li>{% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}</li>
<li>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
</ul>
{{ form.type }}</p>
<p class="date">{{ form.start_date.errors }}<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label> {{ form.start_date }}</p>
<p class="date">{{ form.end_date.errors }}<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label> {{ form.end_date }}</p>
<p class="until">{{ form.until.errors }}<label for="{{ form.until.name }}">{{ form.until.label }}</label> {{ form.until }}</p>
<p>{{ form.title.errors }}<label for="{{ form.title.name }}">{{ form.title.label }}</label> {{ form.title }}</p>
<p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
<p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
<p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
{% if user.is_com_admin %}
<p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
{{ form.automoderation }}</p>
{% endif %}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" class="btn btn-blue"/></p>
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}" /></p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script>
$( function() {
var type = $('input[name=type]');
var dates = $('.date');
var until = $('.until');
function update_targets () {
type_checked = $('input[name=type]:checked');
if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") {
dates.show();
until.hide();
} else if (type_checked.val() == "WEEKLY") {
dates.show();
until.show();
} else {
dates.hide();
until.hide();
}
}
update_targets();
type.change(update_targets);
} );
</script>
{% endblock %}

View File

@ -1,278 +1,166 @@
{% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %}
{% from 'core/macros.jinja' import tweet_quick, fb_quick %}
{% block title %}
{% trans %}News{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
{# Atom feed discovery, not really css but also goes there #}
<link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
{% endblock %}
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
{% endblock %}
{% block content %}
{% if user.is_com_admin %}
<div id="news_admin">
<a class="button" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
</div>
<br>
{% endif %}
<div id="news">
<div id="left_column" class="news_column">
<h3>
{% trans %}Events today and the next few days{% endtrans %}
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %}
</a>
{% endif %}
{% if user.is_com_admin %}
<a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">
{% trans %}Administrate news{% endtrans %}
</a>
<br>
{% endif %}
<section id="upcoming-events">
{% if not news_dates %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
{% for news in object_list.filter(type="NOTICE") %}
<section class="news_notice">
<h4><a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div class="news_content">{{ news.summary|markdown }}</div>
</section>
{% endfor %}
{% for news in object_list.filter(dates__start_date__lte=timezone.now(), dates__end_date__gte=timezone.now(), type="CALL") %}
<section class="news_call">
<h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
{% else %}
{% for day, dates_group in news_dates.items() %}
<div class="news_events_group">
<div class="news_events_group_date">
<div>
<div>{{ day|date('D') }}</div>
<div class="day">{{ day|date('d') }}</div>
<div>{{ day|date('b') }}</div>
</div>
</div>
<div class="news_events_group_items">
{% for date in dates_group %}
<article
class="news_event"
{%- if not date.news.is_published -%}
x-data="{newsState: AlertState.PENDING}"
{% else %}
x-data="{newsState: AlertState.DISPLAYED}"
{%- endif -%}
>
{# if a non published news is in the object list,
the logged user is either an admin or the news author #}
{{ news_moderation_alert(date.news, user, "newsState") }}
<div
x-show="newsState !== AlertState.DELETED"
>
<header class="row gap">
{% if date.news.club.logo %}
<img src="{{ date.news.club.logo.url }}" alt="{{ date.news.club }}"/>
{% else %}
<img src="{{ static("com/img/news.png") }}" alt="{{ date.news.club }}"/>
{% endif %}
<div class="header_content">
<h4>
<a href="{{ url('com:news_detail', news_id=date.news_id) }}">
{{ date.news.title }}
</a>
</h4>
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
<div class="news_date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</div>
</div>
</header>
<div class="news_content markdown">
{{ date.news.summary|markdown }}
</div>
</div>
</article>
{% endfor %}
<div class="news_content">{{ news.summary|markdown }}</div>
</section>
{% endfor %}
{% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__type="EVENT", news__is_moderated=True).datetimes('start_date', 'day') %}
<h3>{% trans %}Events today and the next few days{% endtrans %}</h3>
{% if events_dates %}
{% for d in events_dates %}
<div class="news_events_group">
<div class="news_events_group_date">
<div>
<div>{{ d|localtime|date('D') }}</div>
<div class="day">{{ d|localtime|date('d') }}</div>
<div>{{ d|localtime|date('b') }}</div>
</div>
</div>
{% endfor %}
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
<template x-for="newsList in Object.values(groupedDates())">
<div class="news_events_group">
<div class="news_events_group_date">
<div x-data="{day: newsList[0].start_date}">
<div x-text="day.toLocaleString('{{ get_language() }}', { weekday: 'short' }).substring(0, 3)"></div>
<div
class="day"
x-text="day.toLocaleString('{{ get_language() }}', { day: 'numeric' })"
></div>
<div x-text="day.toLocaleString('{{ get_language() }}', { month: 'short' }).substring(0, 3)"></div>
<div class="news_events_group_items">
{% for news in object_list.filter(dates__start_date__gte=d,
dates__start_date__lte=d+timedelta(days=1),
type="EVENT").exclude(dates__end_date__lt=timezone.now())
.order_by('dates__start_date') %}
<section class="news_event">
<div class="club_logo">
{% if news.club.logo %}
<img src="{{ news.club.logo.url }}" alt="{{ news.club }}" />
{% else %}
<img src="{{ static("com/img/news.png") }}" alt="{{ news.club }}" />
{% endif %}
</div>
<h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></div>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div class="news_content">{{ news.summary|markdown }}
<div class="button_bar">
{{ fb_quick(news) }}
{{ tweet_quick(news) }}
</div>
</div>
<div class="news_events_group_items">
<template x-for="newsDate in newsList" :key="newsDate.id">
<article
class="news_event"
x-data="{ newsState: newsDate.news.is_published ? AlertState.PUBLISHED : AlertState.PENDING }"
>
<template x-if="!newsDate.news.is_published">
{{ news_moderation_alert("newsDate.news.id", user, "newsState") }}
</template>
<div x-show="newsState !== AlertState.DELETED">
<header class="row gap">
<img
:src="newsDate.news.club.logo || '{{ static("com/img/news.png") }}'"
:alt="newsDate.news.club.name"
/>
<div class="header_content">
<h4>
<a :href="newsDate.news.url" x-text="newsDate.news.title"></a>
</h4>
<a :href="newsDate.news.club.url" x-text="newsDate.news.club.name"></a>
<div class="news_date">
<time
:datetime="newsDate.start_date.toISOString()"
x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
></time> -
<time
:datetime="newsDate.end_date.toISOString()"
x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
></time>
</div>
</div>
</header>
{# The API returns a summary in html.
It's generated from our markdown subset, so it should be safe #}
<div class="news_content markdown" x-html="newsDate.news.summary"></div>
</div>
</article>
</template>
</div>
</div>
</template>
<div id="load-more-news-button" :aria-busy="loading">
<button class="btn btn-grey" x-show="!loading && hasNext" @click="loadMore()">
{% trans %}See more{% endtrans %} &nbsp;<i class="fa fa-arrow-down"></i>
</button>
<p x-show="!loading && !hasNext">
<em>
{% trans trimmed %}
It was too short.
You already reached the end of the upcoming events list.
{% endtrans %}
</em>
</p>
</div>
</div>
{% endif %}
</section>
<h3>
{% trans %}All coming events{% endtrans %}
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3>
<ics-calendar
x-data
x-ref="calendar"
@news-moderated.window="
if ($event.target !== $refs.calendar){
// Avoid triggering a refresh with a dispatch
// from the calendar itself
$refs.calendar.refreshEvents($event);
}
"
@calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
@calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
@calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
locale="{{ get_language() }}"
can_moderate="{{ user.has_perm("com.moderate_news") }}"
can_delete="{{ user.has_perm("com.delete_news") }}"
></ics-calendar>
</div>
<div id="right_column">
<div id="links">
<h3>{% trans %}Links{% endtrans %}</h3>
<div id="links_content">
<h4>{% trans %}Our services{% endtrans %}</h4>
<ul>
<li>
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
</li>
</ul>
<br>
<h4>{% trans %}Social media{% endtrans %}</h4>
<ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">
{% trans %}Discord AE{% endtrans %}
</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/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/">
Facebook
</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">
Instagram
</a>
</li>
</ul>
</section>
{% endfor %}
</div>
</div>
<div id="birthdays">
<h3>{% trans %}Birthdays{% endtrans %}</h3>
<div id="birthdays_content">
{%- if user.has_perm("core.view_user") -%}
<ul class="birthdays_year">
{%- for year, users in birthdays -%}
<li>
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
<ul>
{%- for u in users -%}
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
{%- endfor -%}
</ul>
</li>
{%- endfor -%}
</ul>
{%- elif not user.was_subscribed -%}
{# The user cannot view birthdays, because he never subscribed #}
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
{%- else -%}
{# There is another reason why user cannot view birthdays (maybe he is banned)
but we cannot know exactly what is this reason #}
<p>{% trans %}You cannot access this content{% endtrans %}</p>
{%- endif -%}
</div>
{% endfor %}
{% else %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div>
{% endif %}
{% set coming_soon = object_list.filter(dates__start_date__gte=timezone.now()+timedelta(days=5),
type="EVENT").order_by('dates__start_date') %}
{% if coming_soon %}
<h3>{% trans %}Coming soon... don't miss!{% endtrans %}</h3>
{% for news in coming_soon %}
<section class="news_coming_soon">
<a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a>
<span class="news_date">{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</section>
{% endfor %}
{% endif %}
<h3>{% trans %}All coming events{% endtrans %}</h3>
<iframe
src="https://embed.styledcalendar.com/#2mF2is8CEXhr4ADcX6qN"
title="Styled Calendar"
class="styled-calendar-container"
style="width: 100%; border: none; height: 1060px"
data-cy="calendar-embed-iframe">
</iframe>
</div>
<div id="right_column" class="news_column">
<div id="agenda">
<div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
<div id="agenda_content">
{% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
news__is_moderated=True, news__type__in=["WEEKLY",
"EVENT"]).order_by('start_date', 'end_date') %}
<div class="agenda_item">
<div class="agenda_date">
<strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
</div>
<div class="agenda_time">
<span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div>
<strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
<a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
</div>
<div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
</div>
{% endfor %}
</div>
</div>
<div id="birthdays">
<div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
<div id="birthdays_content">
{% if user.is_subscribed %}
{# Cache request for 1 hour #}
{% cache 3600 "birthdays" %}
<ul class="birthdays_year">
{% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
<li>
{% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
<ul>
{% for u in birthdays.filter(date_of_birth__year=d.year) %}
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{% endcache %}
{% else %}
<p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -10,10 +10,6 @@
{% trans %}Poster{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
{% endblock %}
{% block content %}
<div id="poster_list">

View File

@ -5,10 +5,6 @@
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
{% endblock %}
{% block content %}
<div id="poster_list">

View File

@ -3,7 +3,7 @@
<head>
<title>{% trans %}Slideshow{% endtrans %}</title>
<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>
</head>
<body>

View File

@ -12,25 +12,18 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import timedelta
from unittest.mock import patch
import pytest
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
from django.utils import html
from django.utils.timezone import localtime, now
from django.utils.translation import gettext as _
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
from core.models import AnonymousUser, RealGroup, User
@pytest.fixture()
@ -56,7 +49,9 @@ class TestCom(TestCase):
@classmethod
def setUpTestData(cls):
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])
def setUp(self):
@ -104,7 +99,9 @@ class TestCom(TestCase):
response = self.client.get(reverse("core:index"))
self.assertContains(
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):
@ -112,16 +109,9 @@ class TestCom(TestCase):
self.assertNotContains(
response,
text=html.escape(_("You need to subscribe to access this content")),
)
def test_birthday_old_subscibed_user(self):
self.client.force_login(User.objects.get(username="old_subscriber"))
response = self.client.get(reverse("core:index"))
self.assertNotContains(
response,
text=html.escape(_("You need to subscribe to access this content")),
text=html.escape(
_("You need an up to date subscription to access this content")
),
)
@ -144,8 +134,15 @@ class TestNews(TestCase):
@classmethod
def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity")
cls.new = baker.make(News)
cls.author = cls.new.author
new = News.objects.create(
title="dummy new",
summary="This is a dummy new",
content="Look at that beautiful dummy new",
author=User.objects.get(username="subscriber"),
club=Club.objects.first(),
)
cls.new = new
cls.author = new.author
cls.sli = User.objects.get(username="sli")
cls.anonymous = AnonymousUser()
@ -160,15 +157,15 @@ class TestNews(TestCase):
def test_news_viewer(self):
"""Test that moderated news can be viewed by anyone
and not moderated news only by com admins and by their author.
and not moderated news only by com admins.
"""
# by default news aren't moderated
# by default a news isn't moderated
assert self.new.can_be_viewed_by(self.com_admin)
assert self.new.can_be_viewed_by(self.author)
assert not self.new.can_be_viewed_by(self.sli)
assert not self.new.can_be_viewed_by(self.anonymous)
assert not self.new.can_be_viewed_by(self.author)
self.new.is_published = True
self.new.is_moderated = True
self.new.save()
assert self.new.can_be_viewed_by(self.com_admin)
assert self.new.can_be_viewed_by(self.sli)
@ -176,11 +173,11 @@ class TestNews(TestCase):
assert self.new.can_be_viewed_by(self.author)
def test_news_editor(self):
"""Test that only com admins and the original author can edit news."""
"""Test that only com admins can edit news."""
assert self.new.can_be_edited_by(self.com_admin)
assert self.new.can_be_edited_by(self.author)
assert not self.new.can_be_edited_by(self.sli)
assert not self.new.can_be_edited_by(self.anonymous)
assert not self.new.can_be_edited_by(self.author)
class TestWeekmailArticle(TestCase):
@ -230,105 +227,3 @@ class TestPoster(TestCase):
assert not self.poster.is_owned_by(self.susbcriber)
assert self.poster.is_owned_by(self.sli)
class TestNewsCreation(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.user = subscriber_user.make()
baker.make(Membership, user=cls.user, club=cls.club, role=5)
def setUp(self):
self.client.force_login(self.user)
self.start = now() + timedelta(days=1)
self.end = self.start + timedelta(hours=5)
self.valid_payload = {
"title": "Test news",
"summary": "This is a test news",
"content": "This is a test news",
"club": self.club.pk,
"is_weekly": False,
"start_date": self.start,
"end_date": self.end,
}
def test_create_news(self):
response = self.client.post(reverse("com:news_new"), self.valid_payload)
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news"
assert not created.is_published
dates = list(created.dates.values("start_date", "end_date"))
assert dates == [{"start_date": self.start, "end_date": self.end}]
def test_create_news_multiple_dates(self):
self.valid_payload["is_weekly"] = True
self.valid_payload["occurrences"] = 2
response = self.client.post(reverse("com:news_new"), self.valid_payload)
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
dates = list(
created.dates.values("start_date", "end_date").order_by("start_date")
)
assert dates == [
{"start_date": self.start, "end_date": self.end},
{
"start_date": self.start + timedelta(days=7),
"end_date": self.end + timedelta(days=7),
},
]
def test_edit_news(self):
news = baker.make(News, author=self.user, is_published=True)
baker.make(
NewsDate,
news=news,
start_date=self.start + timedelta(hours=1),
end_date=self.end + timedelta(hours=1),
_quantity=2,
)
response = self.client.post(
reverse("com:news_edit", kwargs={"news_id": news.id}), self.valid_payload
)
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news"
assert not created.is_published
dates = list(created.dates.values("start_date", "end_date"))
assert dates == [{"start_date": self.start, "end_date": self.end}]
def test_ics_updated(self):
"""Test that the internal ICS is updated when news are created"""
# we will just test that the ICS is modified.
# Checking that the ICS is *well* modified is up to the ICS tests
with patch("com.calendar.IcsCalendar.make_internal") as mocked:
self.client.post(reverse("com:news_new"), self.valid_payload)
mocked.assert_called()
# The ICS file should also change after an update
self.valid_payload["is_weekly"] = True
self.valid_payload["occurrences"] = 2
last_news = News.objects.order_by("id").last()
with patch("com.calendar.IcsCalendar.make_internal") as mocked:
self.client.post(
reverse("com:news_edit", kwargs={"news_id": last_news.id}),
self.valid_payload,
)
mocked.assert_called()
@pytest.mark.django_db
def test_feed(client):
"""Smoke test that checks that the atom feed is working"""
Site.objects.clear_cache()
with assertNumQueries(2):
# get sith domain with Site api: 1 request
# get all news and related info: 1 request
resp = client.get(reverse("com:news_feed"))
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/rss+xml; charset=utf-8"

View File

@ -1,250 +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
from urllib.parse import quote
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.http import HttpResponse
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import now
from model_bakery import baker, seq
from pytest_django.asserts import assertNumQueries
from com.calendar import IcsCalendar
from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User
@dataclass
class MockResponse:
ok: bool
value: str
@property
def content(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("requests.get", 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)
def test_fetch_error(self, client: Client, mock_request: MagicMock):
mock_request.return_value = MockResponse(ok=False, value="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(ok=True, value="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()
@pytest.mark.django_db
class TestModerateNews:
@pytest.mark.parametrize("news_is_published", [True, False])
def test_moderation_ok(self, client: Client, news_is_published: bool): # noqa FBT
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="moderate_news")]
)
# The API call should work even if the news is initially moderated.
# In the latter case, the result should be a noop, rather than an error.
news = baker.make(News, is_published=news_is_published)
initial_moderator = news.moderator
client.force_login(user)
response = client.patch(
reverse("api:moderate_news", kwargs={"news_id": news.id})
)
# if it wasn't moderated, it should now be moderated and the moderator should
# be the user that made the request.
# If it was already moderated, it should be a no-op, but not an error
assert response.status_code == 200
news.refresh_from_db()
assert news.is_published
if not news_is_published:
assert news.moderator == user
else:
assert news.moderator == initial_moderator
def test_moderation_forbidden(self, client: Client):
user = baker.make(User)
news = baker.make(News, is_published=False)
client.force_login(user)
response = client.patch(
reverse("api:moderate_news", kwargs={"news_id": news.id})
)
assert response.status_code == 403
news.refresh_from_db()
assert not news.is_published
@pytest.mark.django_db
class TestDeleteNews:
def test_delete_news_ok(self, client: Client):
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="delete_news")]
)
news = baker.make(News)
client.force_login(user)
response = client.delete(
reverse("api:delete_news", kwargs={"news_id": news.id})
)
assert response.status_code == 200
assert not News.objects.filter(id=news.id).exists()
def test_delete_news_forbidden(self, client: Client):
user = baker.make(User)
news = baker.make(News)
client.force_login(user)
response = client.delete(
reverse("api:delete_news", kwargs={"news_id": news.id})
)
assert response.status_code == 403
assert News.objects.filter(id=news.id).exists()
class TestFetchNewsDates(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
cls.dates = baker.make(
NewsDate,
_quantity=5,
_bulk_create=True,
start_date=seq(value=now(), increment_by=timedelta(days=1)),
end_date=seq(
value=now() + timedelta(hours=2), increment_by=timedelta(days=1)
),
news=iter(
baker.make(News, is_published=True, _quantity=5, _bulk_create=True)
),
)
cls.dates.append(
baker.make(
NewsDate,
start_date=now() + timedelta(days=2, hours=1),
end_date=now() + timedelta(days=2, hours=5),
news=baker.make(News, is_published=True),
)
)
cls.dates.sort(key=lambda d: d.start_date)
def test_num_queries(self):
with assertNumQueries(2):
self.client.get(reverse("api:fetch_news_dates"))
def test_html_format(self):
"""Test that when the summary is asked in html, the summary is in html."""
summary_1 = "# First event\nThere is something happening.\n"
self.dates[0].news.summary = summary_1
self.dates[0].news.save()
summary_2 = (
"# Second event\n"
"There is something happening **for real**.\n"
"Everything is [here](https://youtu.be/dQw4w9WgXcQ)\n"
)
self.dates[1].news.summary = summary_2
self.dates[1].news.save()
response = self.client.get(
reverse("api:fetch_news_dates") + "?page_size=2&text_format=html"
)
assert response.status_code == 200
dates = response.json()["results"]
assert dates[0]["news"]["summary"] == markdown(summary_1)
assert dates[1]["news"]["summary"] == markdown(summary_2)
def test_fetch(self):
after = quote((now() + timedelta(days=1)).isoformat())
response = self.client.get(
reverse("api:fetch_news_dates") + f"?page_size=3&after={after}"
)
assert response.status_code == 200
dates = response.json()["results"]
assert [d["id"] for d in dates] == [d.id for d in self.dates[1:4]]

View File

@ -1,42 +0,0 @@
import itertools
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from com.models import News
from core.models import User
class TestNewsViewableBy(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
cls.users = baker.make(User, _quantity=3, _bulk_create=True)
# There are six news and six authors.
# Each author has one moderated and one non-moderated news
cls.news = baker.make(
News,
author=itertools.cycle(cls.users),
is_published=iter([True, True, True, False, False, False]),
_quantity=6,
_bulk_create=True,
)
def test_admin_can_view_everything(self):
"""Test with a user that can view non moderated news."""
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_unmoderated_news")],
)
assert set(News.objects.viewable_by(user)) == set(self.news)
def test_normal_user_can_view_moderated_and_self_news(self):
"""Test that basic users can view moderated news and news they authored."""
user = self.news[0].author
assert set(News.objects.viewable_by(user)) == {
self.news[0],
self.news[1],
self.news[2],
self.news[3],
}

View File

@ -25,10 +25,9 @@ from com.views import (
NewsCreateView,
NewsDeleteView,
NewsDetailView,
NewsFeed,
NewsEditView,
NewsListView,
NewsModerateView,
NewsUpdateView,
PosterCreateView,
PosterDeleteView,
PosterEditView,
@ -74,14 +73,13 @@ urlpatterns = [
name="weekmail_article_edit",
),
path("news/", NewsListView.as_view(), name="news_list"),
path("news/feed/", NewsFeed(), name="news_feed"),
path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
path("news/create/", NewsCreateView.as_view(), name="news_new"),
path("news/<int:news_id>/edit/", NewsUpdateView.as_view(), name="news_edit"),
path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
path(
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
),
path("news/<int:news_id>/edit/", NewsEditView.as_view(), name="news_edit"),
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
path(

View File

@ -21,15 +21,12 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import itertools
from datetime import date, timedelta
from smtplib import SMTPRecipientsRefused
from typing import Any
from dateutil.relativedelta import relativedelta
from datetime import timedelta
from smtplib import SMTPRecipientsRefused
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max
from django.forms.models import modelform_factory
@ -37,22 +34,24 @@ from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.timezone import localdate, now
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing
from com.calendar import IcsCalendar
from com.forms import NewsDateForm, NewsForm, PosterForm
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
from core.auth.mixins import (
from core.models import Notification, RealGroup, User
from core.views import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
PermissionOrAuthorRequiredMixin,
QuickNotifMixin,
TabedViewMixin,
)
from core.models import User
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
# Sith object
@ -60,47 +59,92 @@ from core.views.widgets.markdown import MarkdownInput
sith = Sith.objects.first
class PosterForm(forms.ModelForm):
class Meta:
model = Poster
fields = [
"name",
"file",
"club",
"screens",
"date_begin",
"date_end",
"display_time",
]
widgets = {"screens": forms.CheckboxSelectMultiple}
help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
date_begin = forms.DateTimeField(
label=_("Start date"),
widget=SelectDateTime,
required=True,
initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
)
date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if self.user and not self.user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter(
id__in=self.user.clubs_with_rights
)
self.fields.pop("display_time")
class ComTabsMixin(TabedViewMixin):
def get_tabs_title(self):
return _("Communication administration")
def get_list_of_tabs(self):
return [
{"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")},
tab_list = []
tab_list.append(
{"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")}
)
tab_list.append(
{
"url": reverse("com:weekmail_destinations"),
"slug": "weekmail_destinations",
"name": _("Weekmail destinations"),
},
{
"url": reverse("com:info_edit"),
"slug": "info",
"name": _("Info message"),
},
}
)
tab_list.append(
{"url": reverse("com:info_edit"), "slug": "info", "name": _("Info message")}
)
tab_list.append(
{
"url": reverse("com:alert_edit"),
"slug": "alert",
"name": _("Alert message"),
},
}
)
tab_list.append(
{
"url": reverse("com:mailing_admin"),
"slug": "mailings",
"name": _("Mailing lists administration"),
},
}
)
tab_list.append(
{
"url": reverse("com:poster_list"),
"slug": "posters",
"name": _("Posters list"),
},
}
)
tab_list.append(
{
"url": reverse("com:screen_list"),
"slug": "screens",
"name": _("Screens list"),
},
]
}
)
return tab_list
class IsComAdminMixin(AccessMixin):
class IsComAdminMixin(View):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin:
raise PermissionDenied
@ -140,86 +184,180 @@ class WeekmailDestinationEditView(ComEditView):
# News
class NewsCreateView(PermissionRequiredMixin, CreateView):
"""View to either create or update News."""
model = News
form_class = NewsForm
template_name = "com/news_edit.jinja"
permission_required = "com.add_news"
def get_date_form_kwargs(self) -> dict[str, Any]:
"""Get initial data for NewsDateForm"""
if self.request.method == "POST":
return {"data": self.request.POST}
return {}
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"author": self.request.user,
"date_form": NewsDateForm(**self.get_date_form_kwargs()),
class NewsForm(forms.ModelForm):
class Meta:
model = News
fields = ["type", "title", "club", "summary", "content", "author"]
widgets = {
"author": forms.HiddenInput,
"type": forms.RadioSelect,
"summary": MarkdownInput,
"content": MarkdownInput,
}
def get_initial(self):
init = super().get_initial()
# if the id of a club is provided, select it by default
if club_id := self.request.GET.get("club"):
init["club"] = Club.objects.filter(id=club_id).first()
return init
start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=False
)
end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
until = forms.DateTimeField(label=_("Until"), widget=SelectDateTime, required=False)
automoderation = forms.BooleanField(label=_("Automoderation"), required=False)
def clean(self):
self.cleaned_data = super().clean()
if self.cleaned_data["type"] != "NOTICE":
if not self.cleaned_data["start_date"]:
self.add_error(
"start_date", ValidationError(_("This field is required."))
)
if not self.cleaned_data["end_date"]:
self.add_error(
"end_date", ValidationError(_("This field is required."))
)
if (
not self.has_error("start_date")
and not self.has_error("end_date")
and self.cleaned_data["start_date"] > self.cleaned_data["end_date"]
):
self.add_error(
"end_date",
ValidationError(
_("You crazy? You can not finish an event before starting it.")
),
)
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
self.add_error("until", ValidationError(_("This field is required.")))
return self.cleaned_data
def save(self):
ret = super().save()
self.instance.dates.all().delete()
if self.instance.type == "EVENT" or self.instance.type == "CALL":
NewsDate(
start_date=self.cleaned_data["start_date"],
end_date=self.cleaned_data["end_date"],
news=self.instance,
).save()
elif self.instance.type == "WEEKLY":
start_date = self.cleaned_data["start_date"]
end_date = self.cleaned_data["end_date"]
while start_date <= self.cleaned_data["until"]:
NewsDate(
start_date=start_date, end_date=end_date, news=self.instance
).save()
start_date += timedelta(days=7)
end_date += timedelta(days=7)
return ret
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
class NewsEditView(CanEditMixin, UpdateView):
model = News
form_class = NewsForm
template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id"
permission_required = "com.edit_news"
def get_initial(self):
news_date: NewsDate = self.object.dates.order_by("id").first()
if news_date is None:
return {"start_date": None, "end_date": None}
return {"start_date": news_date.start_date, "end_date": news_date.end_date}
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and "preview" not in request.POST:
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
response = super().form_valid(form) # Does the saving part
IcsCalendar.make_internal()
return response
def get_date_form_kwargs(self) -> dict[str, Any]:
"""Get initial data for NewsDateForm"""
response = {}
if self.request.method == "POST":
response["data"] = self.request.POST
dates = list(self.object.dates.order_by("id"))
if len(dates) == 0:
return {}
response["instance"] = dates[0]
occurrences = NewsDateForm.get_occurrences(len(dates))
if occurrences is not None:
response["initial"] = {"is_weekly": True, "occurrences": occurrences}
return response
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"author": self.request.user,
"date_form": NewsDateForm(**self.get_date_form_kwargs()),
}
self.object = form.save()
if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
self.object.moderator = self.request.user
self.object.is_moderated = True
self.object.save()
else:
self.object.is_moderated = False
self.object.save()
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
):
if not u.notifications.filter(
type="NEWS_MODERATION", viewed=False
).exists():
Notification(
user=u,
url=reverse(
"com:news_detail", kwargs={"news_id": self.object.id}
),
type="NEWS_MODERATION",
).save()
return super().form_valid(form)
class NewsDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
class NewsCreateView(CanCreateMixin, CreateView):
model = News
form_class = NewsForm
template_name = "com/news_edit.jinja"
def get_initial(self):
init = {"author": self.request.user}
if "club" not in self.request.GET:
return init
init["club"] = Club.objects.filter(id=self.request.GET["club"]).first()
return init
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and "preview" not in request.POST:
return self.form_valid(form)
else:
self.object = form.instance
return self.form_invalid(form)
def form_valid(self, form):
self.object = form.save()
if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
self.object.moderator = self.request.user
self.object.is_moderated = True
self.object.save()
else:
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
):
if not u.notifications.filter(
type="NEWS_MODERATION", viewed=False
).exists():
Notification(
user=u,
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
).save()
return super().form_valid(form)
class NewsDeleteView(CanEditMixin, DeleteView):
model = News
pk_url_kwarg = "news_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:news_admin_list")
permission_required = "com.delete_news"
class NewsModerateView(PermissionRequiredMixin, DetailView):
class NewsModerateView(CanEditMixin, SingleObjectMixin):
model = News
pk_url_kwarg = "news_id"
permission_required = "com.moderate_news"
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "remove" in request.GET:
self.object.is_published = False
self.object.is_moderated = False
else:
self.object.is_published = True
self.object.is_moderated = True
self.object.moderator = request.user
self.object.save()
if "next" in self.request.GET:
@ -227,112 +365,36 @@ class NewsModerateView(PermissionRequiredMixin, DetailView):
return redirect("com:news_admin_list")
class NewsAdminListView(PermissionRequiredMixin, ListView):
class NewsAdminListView(CanEditMixin, ListView):
model = News
template_name = "com/news_admin_list.jinja"
queryset = News.objects.select_related(
"club", "author", "moderator"
).prefetch_related("dates")
permission_required = ["com.moderate_news", "com.delete_news"]
queryset = News.objects.all()
class NewsListView(TemplateView):
class NewsListView(CanViewMixin, ListView):
model = News
template_name = "com/news_list.jinja"
queryset = News.objects.filter(is_moderated=True)
def get_birthdays(self):
if not self.request.user.has_perm("core.view_user"):
return []
return itertools.groupby(
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["NewsDate"] = NewsDate
kwargs["timedelta"] = timedelta
kwargs["birthdays"] = (
User.objects.filter(
date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day,
is_subscriber_viewable=True,
)
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"),
key=lambda u: u.date_of_birth.year,
.order_by("-date_of_birth")
)
def get_last_day(self) -> date | None:
"""Get the last day when news will be displayed
The returned day is the third one where something happen.
For example, if there are 6 events : A on 15/03, B and C on 17/03,
D on 20/03, E on 21/03 and F on 22/03 ;
then the result is 20/03.
"""
dates = list(
NewsDate.objects.filter(end_date__gt=now())
.order_by("start_date")
.values_list("start_date__date", flat=True)
.distinct()[:4]
)
return dates[-1] if len(dates) > 0 else None
def get_news_dates(self, until: date) -> dict[date, list[date]]:
"""Return the event dates to display.
The selected events are the ones that happens between
right now and the given day (included).
"""
return {
date: list(dates)
for date, dates in itertools.groupby(
NewsDate.objects.viewable_by(self.request.user)
.filter(end_date__gt=now(), start_date__date__lte=until)
.order_by("start_date")
.select_related("news", "news__club"),
key=lambda d: d.start_date.date(),
)
}
def get_context_data(self, **kwargs):
last_day = self.get_last_day()
return super().get_context_data(**kwargs) | {
"news_dates": self.get_news_dates(until=last_day)
if last_day is not None
else {},
"birthdays": self.get_birthdays(),
"last_day": last_day,
}
return kwargs
class NewsDetailView(CanViewMixin, DetailView):
model = News
template_name = "com/news_detail.jinja"
pk_url_kwarg = "news_id"
queryset = News.objects.select_related("club", "author", "moderator")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
class NewsFeed(Feed):
title = _("News")
link = reverse_lazy("com:news_list")
description = _("All incoming events")
def items(self):
return (
NewsDate.objects.filter(
news__is_published=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
)
.select_related("news", "news__author")
.order_by("-start_date")
)
def item_title(self, item: NewsDate):
return item.news.title
def item_description(self, item: NewsDate):
return item.news.summary
def item_link(self, item: NewsDate):
return item.news.get_absolute_url()
def item_author_name(self, item: NewsDate):
return item.news.author.get_display_name()
# Weekmail
@ -628,12 +690,8 @@ class PosterEditBaseView(UpdateView):
def get_initial(self):
return {
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_begin
else None,
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_end
else None,
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"),
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"),
}
def dispatch(self, request, *args, **kwargs):

View File

@ -15,32 +15,17 @@
from django.contrib import admin
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.auth.models import Permission
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
from core.models import Group, OperationLog, Page, SithFile, User
admin.site.unregister(AuthGroup)
@admin.register(Group)
class GroupAdmin(admin.ModelAdmin):
list_display = ("name", "description", "is_manually_manageable")
list_filter = ("is_manually_manageable",)
list_display = ("name", "description", "is_meta")
list_filter = ("is_meta",)
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)
@ -52,24 +37,10 @@ class UserAdmin(admin.ModelAdmin):
"profile_pict",
"avatar_pict",
"scrub_pict",
"user_permissions",
"groups",
)
inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"]
@admin.register(UserBan)
class UserBanAdmin(admin.ModelAdmin):
list_display = ("user", "ban_group", "created_at", "expires_at")
autocomplete_fields = ("user", "ban_group")
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",)
@admin.register(Page)
class PageAdmin(admin.ModelAdmin):
list_display = ("name", "_full_name", "owner_group")

View File

@ -11,7 +11,10 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing
from core.auth.api_permissions import CanAccessLookup, CanView
from core.api_permissions import (
CanAccessLookup,
CanView,
)
from core.models import Group, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,

View File

@ -3,8 +3,7 @@
Some permissions are global (like `IsInGroup` or `IsRoot`),
and some others are per-object (like `CanView` or `CanEdit`).
Example:
```python
Examples:
# restrict all the routes of this controller
# to subscribed users
@api_controller("/foo", permissions=[IsSubscriber])
@ -34,14 +33,10 @@ Example:
]
def bar_delete(self, bar_id: int):
# ...
```
"""
import operator
from functools import reduce
from typing import Any
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
@ -59,46 +54,6 @@ class IsInGroup(BasePermission):
return request.user.is_in_group(pk=self._group_pk)
class HasPerm(BasePermission):
"""Check that the user has the required perm.
If multiple perms are given, a comparer function can also be passed,
in order to change the way perms are checked.
Example:
```python
# this route will require both permissions
@route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
def foo(self): ...
# This route will require at least one of the perm,
# but it's not mandatory to have all of them
@route.put(
"/bar",
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
)
def bar(self): ...
"""
def __init__(
self, perms: str | Permission | list[str | Permission], op=operator.and_
):
"""
Args:
perms: a permission or a list of permissions the user must have
op: An operator to combine multiple permissions (in most cases,
it will be either `operator.and_` or `operator.or_`)
"""
super().__init__()
if not isinstance(perms, (list, tuple, set)):
perms = [perms]
self._operator = op
self._perms = perms
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
class IsRoot(BasePermission):
"""Check that the user is root."""

View File

@ -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)
)

View File

@ -1,287 +0,0 @@
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
# - Sli <antoine@bartuccio.fr>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from __future__ import annotations
import types
import warnings
from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.views.generic.base import View
if TYPE_CHECKING:
from django.db.models import Model
from core.models import User
def can_edit_prop(obj: Any, user: User) -> bool:
"""Can the user edit the properties of the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object properties else False
Example:
```python
if not can_edit_prop(self.object ,request.user):
raise PermissionDenied
```
"""
return obj is None or user.is_owner(obj)
def can_edit(obj: Any, user: User) -> bool:
"""Can the user edit the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object else False
Example:
```python
if not can_edit(self.object, request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_edit(obj):
return True
return can_edit_prop(obj, user)
def can_view(obj: Any, user: User) -> bool:
"""Can the user see the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to see object else False
Example:
```python
if not can_view(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_view(obj):
return True
return can_edit(obj, user)
class GenericContentPermissionMixinBuilder(View):
"""Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based
on two properties.
Attributes:
raised_error: permission to be raised
"""
raised_error = PermissionDenied
@staticmethod
def permission_function(obj: Any, user: User) -> bool:
"""Function to test permission with."""
return False
@classmethod
def get_permission_function(cls, obj, user):
return cls.permission_function(obj, user)
def dispatch(self, request, *arg, **kwargs):
if hasattr(self, "get_object") and callable(self.get_object):
self.object = self.get_object()
if not self.get_permission_function(self.object, request.user):
raise self.raised_error
return super().dispatch(request, *arg, **kwargs)
# If we get here, it's a ListView
queryset = self.get_queryset()
l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
if not l_id and queryset.count() != 0:
raise self.raised_error
self._get_queryset = self.get_queryset
def get_qs(self2):
return self2._get_queryset().filter(id__in=l_id)
self.get_queryset = types.MethodType(get_qs, self)
return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def __init_subclass__(cls, **kwargs):
warnings.warn(
f"{cls.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init_subclass__(**kwargs)
def __init__(self, *args, **kwargs):
warnings.warn(
f"{self.__class__.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated:
raise PermissionDenied
return res
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent,
and it will be retricted to the users that are in the
object's owner_group or that pass the `obj.can_be_viewed_by` test.
Raises:
PermissionDenied: If the user cannot see the object
"""
permission_function = can_edit_prop
class CanEditMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to edit this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_edit
class CanViewMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to view this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_view
class FormerSubscriberMixin(AccessMixin):
"""Check if the user was at least an old subscriber.
Raises:
PermissionDenied: if the user never subscribed.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.was_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the object author.
This mixin can be used in combination with `DetailView`,
or another base class that implements the `get_object` method.
Example:
In the following code, a user will be able
to edit news if he has the `com.change_news` permission
or if he tries to edit his own news :
```python
class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
model = News
author_field = "author"
permission_required = "com.change_news"
```
This is more or less equivalent to :
```python
class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
model = News
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if not (
user.has_perm("com.change_news")
or self.object.author == request.user
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
```
"""
author_field: LiteralString = "author"
def has_permission(self):
if not hasattr(self, "get_object"):
raise ImproperlyConfigured(
f"{self.__class__.__name__} is missing the "
"get_object attribute. "
f"Define {self.__class__.__name__}.get_object, "
"or inherit from a class that implement it (like DetailView)"
)
if super().has_permission():
return True
if self.request.user.is_anonymous:
return False
obj: Model = self.get_object()
if not self.author_field.endswith("_id"):
# getting the related model could trigger a db query
# so we will rather get the foreign value than
# the object itself.
self.author_field += "_id"
author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id

View File

@ -7,7 +7,7 @@ from model_bakery import seq
from model_bakery.recipe import Recipe, related
from club.models import Membership
from core.models import Group, User
from core.models import User
from subscription.models import Subscription
active_subscription = Recipe(
@ -60,6 +60,5 @@ board_user = Recipe(
first_name="AE",
last_name=seq("member "),
memberships=related(ae_board_membership),
groups=lambda: [Group.objects.get(club_board=settings.SITH_MAIN_CLUB_ID)],
)
"""A user which is in the board of the AE."""

View File

@ -23,7 +23,7 @@
from datetime import date, timedelta
from io import StringIO
from pathlib import Path
from typing import ClassVar, NamedTuple
from typing import ClassVar
from django.conf import settings
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.base import BaseCommand
from django.db import connection
from django.db.models import Q
from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
@ -46,9 +45,8 @@ from accounting.models import (
SimplifiedAccountingType,
)
from club.models import Club, Membership
from com.calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.models import Group, Page, PageRev, RealGroup, SithFile, User
from core.utils import resize_image
from counter.models import Counter, Product, ProductType, StudentCard
from election.models import Candidature, Election, ElectionList, Role
@ -58,18 +56,6 @@ from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription
class PopulatedGroups(NamedTuple):
root: Group
public: Group
subscribers: Group
old_subscribers: Group
sas_admin: Group
com_admin: Group
counter_admin: Group
accounting_admin: Group
pedagogy_admin: Group
class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = (
@ -83,7 +69,7 @@ class Command(BaseCommand):
# sqlite doesn't support this operation
return
sqlcmd = StringIO()
call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
call_command("sqlsequencereset", *args, stdout=sqlcmd)
cursor = connection.cursor()
cursor.execute(sqlcmd.getvalue())
@ -92,14 +78,26 @@ class Command(BaseCommand):
raise Exception("Never call this command in prod. Never.")
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
site = Site.objects.get_current()
site.domain = settings.SITH_URL
site.name = settings.SITH_NAME
site.save()
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")
groups = self._create_groups()
self._create_ban_groups()
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(
id=0,
@ -125,11 +123,6 @@ class Command(BaseCommand):
unix_name=settings.SITH_MAIN_CLUB["unix_name"],
address=settings.SITH_MAIN_CLUB["address"],
)
main_club.board_group.permissions.add(
*Permission.objects.filter(
codename__in=["view_subscription", "add_subscription"]
)
)
bar_club = Club.objects.create(
id=2,
name=settings.SITH_BAR_MANAGER["name"],
@ -144,10 +137,11 @@ class Command(BaseCommand):
)
self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
self.reset_index("counter")
counters = [
*[
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR")
for bar_id, bar_name in settings.SITH_COUNTER_BARS
],
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
@ -155,21 +149,19 @@ class Command(BaseCommand):
Counter.objects.bulk_create(counters)
bar_groups = []
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
group = Group.objects.create(
name=f"{bar_name} admin", is_manually_manageable=True
)
group = RealGroup.objects.create(name=f"{bar_name} admin")
bar_groups.append(
Counter.edit_groups.through(counter_id=bar_id, group=group)
)
Counter.edit_groups.through.objects.bulk_create(bar_groups)
self.reset_index("counter")
groups.subscribers.viewable_files.add(home_root, club_root)
subscribers.viewable_files.add(home_root, club_root)
Weekmail().save()
# Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
self.now = timezone.now().replace(hour=12, second=0)
self.now = timezone.now().replace(hour=12)
skia = User.objects.create_user(
username="skia",
@ -269,11 +261,21 @@ class Command(BaseCommand):
)
User.groups.through.objects.bulk_create(
[
User.groups.through(group=groups.counter_admin, user=counter),
User.groups.through(group=groups.accounting_admin, user=comptable),
User.groups.through(group=groups.com_admin, user=comunity),
User.groups.through(group=groups.pedagogy_admin, user=tutu),
User.groups.through(group=groups.sas_admin, user=skia),
User.groups.through(
realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
),
]
)
for user in richard, sli, krophil, skia:
@ -334,7 +336,7 @@ Welcome to the wiki page!
content="Fonctionnement de la laverie",
)
groups.public.viewable_page.set(
public_group.viewable_page.set(
[syntax_page, services_page, index_page, laundry_page]
)
@ -380,42 +382,46 @@ Welcome to the wiki page!
parent=main_club,
)
Membership.objects.create(user=skia, club=main_club, role=3)
Membership.objects.create(
user=comunity,
club=bar_club,
start_date=localdate(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
)
Membership.objects.create(
user=sli,
club=troll,
role=9,
description="Padawan Troll",
start_date=localdate() - timedelta(days=17),
)
Membership.objects.create(
user=krophil,
club=troll,
role=10,
description="Maitre Troll",
start_date=localdate() - timedelta(days=200),
)
Membership.objects.create(
user=skia,
club=troll,
role=2,
description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86),
)
Membership.objects.create(
user=richard,
club=troll,
role=2,
description="",
start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100),
Membership.objects.bulk_create(
[
Membership(user=skia, club=main_club, role=3),
Membership(
user=comunity,
club=bar_club,
start_date=localdate(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
),
Membership(
user=sli,
club=troll,
role=9,
description="Padawan Troll",
start_date=localdate() - timedelta(days=17),
),
Membership(
user=krophil,
club=troll,
role=10,
description="Maitre Troll",
start_date=localdate() - timedelta(days=200),
),
Membership(
user=skia,
club=troll,
role=2,
description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86),
),
Membership(
user=richard,
club=troll,
role=2,
description="",
start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100),
),
]
)
p = ProductType.objects.create(name="Bières bouteilles")
@ -470,7 +476,6 @@ Welcome to the wiki page!
limit_age=18,
)
cons = Product.objects.create(
id=settings.SITH_ECOCUP_CONS,
name="Consigne Eco-cup",
code="CONS",
product_type=verre,
@ -480,7 +485,6 @@ Welcome to the wiki page!
club=main_club,
)
dcons = Product.objects.create(
id=settings.SITH_ECOCUP_DECO,
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre,
@ -509,10 +513,8 @@ Welcome to the wiki page!
club=main_club,
limit_age=18,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
)
groups.old_subscribers.products.add(cotis, cotis2)
subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
old_subscribers.products.add(cotis, cotis2)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
@ -606,6 +608,7 @@ Welcome to the wiki page!
)
# Create an election
ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
el = Election.objects.create(
title="Élection 2017",
description="La roue tourne",
@ -614,10 +617,10 @@ Welcome to the wiki page!
start_date="1942-06-12 10:28:45+01",
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(main_club.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
el.view_groups.add(public_group)
el.edit_groups.add(ae_board_group)
el.candidature_groups.add(subscribers)
el.vote_groups.add(subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
listeT = ElectionList.objects.create(title="Troll", election=el)
pres = Role.objects.create(
@ -681,16 +684,17 @@ Welcome to the wiki page!
friday = self.now
while friday.weekday() != 4:
friday += timedelta(hours=6)
friday.replace(hour=20, minute=0)
friday.replace(hour=20, minute=0, second=0)
# Event
news_dates = []
n = News.objects.create(
title="Apero barman",
summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou",
type="EVENT",
club=bar_club,
author=subscriber,
is_published=True,
is_moderated=True,
moderator=skia,
)
news_dates.append(
@ -704,11 +708,13 @@ Welcome to the wiki page!
title="Repas barman",
summary="Enjoy la fin du semestre!",
content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
"Viens donc t'enjailler avec les autres barmans aux "
"frais du BdF! \\o/"
),
type="EVENT",
club=bar_club,
author=subscriber,
is_published=True,
is_moderated=True,
moderator=skia,
)
news_dates.append(
@ -722,9 +728,10 @@ Welcome to the wiki page!
title="Repas fromager",
summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!",
type="EVENT",
club=bar_club,
author=subscriber,
is_published=True,
is_moderated=True,
moderator=skia,
)
news_dates.append(
@ -738,16 +745,17 @@ Welcome to the wiki page!
title="SdF",
summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!",
type="EVENT",
club=bar_club,
author=subscriber,
is_published=True,
is_moderated=True,
moderator=skia,
)
news_dates.append(
NewsDate(
news=n,
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
@ -756,9 +764,10 @@ Welcome to the wiki page!
summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!",
type="WEEKLY",
club=troll,
author=subscriber,
is_published=True,
is_moderated=True,
moderator=skia,
)
news_dates.extend(
@ -772,9 +781,8 @@ Welcome to the wiki page!
]
)
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(
code="PA00",
@ -891,120 +899,3 @@ Welcome to the wiki page!
start=s.subscription_start,
)
s.save()
def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all()
root_group = Group.objects.create(name="Root", is_manually_manageable=True)
root_group.permissions.add(*list(perms.values_list("pk", flat=True)))
# public has no permission.
# Its purpose is not to link users to permissions,
# but to other objects (like products)
public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
)
old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add(
*list(
perms.filter(
codename__in=[
"view_uv",
"view_uvcomment",
"add_uvcommentreport",
"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")
.exclude(codename__in=["change_uvcomment"])
.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="")

View File

@ -5,14 +5,13 @@ from typing import Iterator
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand
from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, Membership
from core.models import Group, User
from core.models import RealGroup, User
from counter.models import (
Counter,
Customer,
@ -39,10 +38,26 @@ class Command(BaseCommand):
raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...")
users = self.create_users()
users = [
User(
username=self.faker.user_name(),
first_name=self.faker.first_name(),
last_name=self.faker.last_name(),
date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
email=self.faker.email(),
phone=self.faker.phone_number(),
address=self.faker.address(),
)
for _ in range(600)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
User.objects.bulk_create(users, ignore_conflicts=True)
users = list(User.objects.order_by("-id")[: len(users)])
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
self.create_subscriptions(users)
self.stdout.write("Creating club memberships...")
users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
subscribers_now = list(
@ -87,34 +102,11 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self) -> list[User]:
password = make_password("plop")
users = [
User(
username=self.faker.user_name(),
first_name=self.faker.first_name(),
last_name=self.faker.last_name(),
date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
email=self.faker.email(),
phone=self.faker.phone_number(),
address=self.faker.address(),
password=password,
)
for _ in range(600)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
users = User.objects.bulk_create(users, ignore_conflicts=True)
users = list(User.objects.order_by("-id")[: len(users)])
public_group = Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID)
public_group.users.add(*users)
return users
def create_subscriptions(self, users: list[User]):
def prepare_subscription(_user: User, start_date: date) -> Subscription:
def prepare_subscription(user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4)
sub = Subscription(member=_user, payment_method=payment_method)
sub = Subscription(member=user, payment_method=payment_method)
sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
sub.subscription_end = sub.compute_end(duration)
return sub
@ -138,10 +130,6 @@ class Command(BaseCommand):
user, self.faker.past_date(sub.subscription_end)
)
subscriptions.append(sub)
old_subscriber_group = Group.objects.get(
pk=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
)
old_subscriber_group.users.add(*users)
Subscription.objects.bulk_create(subscriptions)
Customer.objects.bulk_create(customers, ignore_conflicts=True)
@ -185,8 +173,7 @@ class Command(BaseCommand):
club=club,
)
)
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
Membership.objects.bulk_create(memberships)
def create_uvs(self):
root = User.objects.get(username="root")
@ -238,7 +225,9 @@ class Command(BaseCommand):
ae = Club.objects.get(unix_name="ae")
other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list(
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
RealGroup.objects.filter(
name__in=["Subscribers", "Old subscribers", "Public"]
)
)
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])

View File

@ -563,21 +563,14 @@ class Migration(migrations.Migration):
fields=[],
options={"proxy": True},
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(
name="RealGroup",
fields=[],
options={"proxy": True},
bases=("core.group",),
managers=[("objects", django.contrib.auth.models.GroupManager())],
managers=[("objects", core.models.RealGroupManager())],
),
migrations.AlterUniqueTogether(
name="page", unique_together={("name", "parent")}

View File

@ -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",
),
),
]

View File

@ -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",
),
),
]

View File

@ -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
),
]

View File

@ -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
),
]

View File

@ -1,16 +0,0 @@
# Generated by Django 4.2.17 on 2025-02-25 14:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0043_bangroup_alter_group_description_alter_user_groups_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="userban",
options={"verbose_name": "user ban", "verbose_name_plural": "user bans"},
),
]

View File

@ -29,21 +29,27 @@ import os
import string
import unicodedata
from datetime import timedelta
from io import BytesIO
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.contrib.auth.models import AbstractUser, UserManager
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.auth.models import AbstractBaseUser, UserManager
from django.contrib.auth.models import (
AnonymousUser as AuthAnonymousUser,
)
from django.contrib.auth.models import (
Group as AuthGroup,
)
from django.contrib.auth.models import (
GroupManager as AuthGroupManager,
)
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.db.models import Exists, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
@ -51,7 +57,6 @@ from django.utils.html import escape
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image
if TYPE_CHECKING:
from pydantic import NonNegativeInt
@ -59,15 +64,33 @@ if TYPE_CHECKING:
from club.models import Club
class Group(AuthGroup):
"""Wrapper around django.auth.Group"""
class RealGroupManager(AuthGroupManager):
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,
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:
return reverse("core:group_list")
@ -83,6 +106,65 @@ class Group(AuthGroup):
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
class MetaGroup(Group):
"""MetaGroups are dynamically created groups.
Generally used with clubs where creating a club creates two groups:
* club-SITH_BOARD_SUFFIX
* club-SITH_MEMBER_SUFFIX
"""
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=False
objects = MetaGroupManager()
class Meta:
proxy = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.is_meta = True
@cached_property
def associated_club(self) -> Club | None:
"""Return the group associated with this meta group.
The result of this function is cached
Returns:
The associated club if it exists, else None
"""
from club.models import Club
if self.name.endswith(settings.SITH_BOARD_SUFFIX):
# replace this with str.removesuffix as soon as Python
# is upgraded to 3.10
club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)]
elif self.name.endswith(settings.SITH_MEMBER_SUFFIX):
club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)]
else:
return None
club = cache.get(f"sith_club_{club_name}")
if club is None:
club = Club.objects.filter(unix_name=club_name).first()
cache.set(f"sith_club_{club_name}", club)
return club
class RealGroup(Group):
"""RealGroups are created by the developer.
Most of the time they match a number in settings to be easily used for permissions.
"""
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=True
objects = RealGroupManager()
class Meta:
proxy = True
def validate_promo(value: int) -> None:
start_year = settings.SITH_SCHOOL_START_YEAR
delta = (localdate() + timedelta(days=180)).year - start_year
@ -128,35 +210,13 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
else:
group = Group.objects.filter(name=name).first()
if group is not None:
name = group.name.replace(" ", "_")
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
cache.set(f"sith_group_{group.id}", group)
cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
else:
cache.set(f"sith_group_{pk_or_name}", "not_found")
return group
class BanGroup(AuthGroup):
"""An anti-group, that removes permissions instead of giving them.
Users are linked to BanGroups through UserBan objects.
Example:
```python
user = User.objects.get(username="...")
ban_group = BanGroup.objects.first()
UserBan.objects.create(user=user, ban_group=ban_group, reason="...")
assert user.ban_groups.contains(ban_group)
```
"""
description = models.TextField(_("description"))
class Meta:
verbose_name = _("ban group")
verbose_name_plural = _("ban groups")
class UserQuerySet(models.QuerySet):
def filter_inactive(self) -> Self:
from counter.models import Refilling, Selling
@ -182,7 +242,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
pass
class User(AbstractUser):
class User(AbstractBaseUser):
"""Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it,
@ -193,28 +253,51 @@ class User(AbstractUser):
Required fields: email, first_name, last_name, date_of_birth
"""
username = models.CharField(
_("username"),
max_length=254,
unique=True,
help_text=_(
"Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
),
validators=[
validators.RegexValidator(
r"^[\w.+-]+$",
_(
"Enter a valid username. This value may contain only "
"letters, numbers "
"and ./+/-/_ characters."
),
)
],
error_messages={"unique": _("A user with that username already exists.")},
)
first_name = models.CharField(_("first name"), max_length=64)
last_name = models.CharField(_("last name"), max_length=64)
email = models.EmailField(_("email address"), unique=True)
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
last_update = models.DateTimeField(_("last update"), auto_now=True)
groups = models.ManyToManyField(
Group,
verbose_name=_("groups"),
is_staff = models.BooleanField(
_("staff status"),
default=False,
help_text=_("Designates whether the user can log into this admin site."),
)
is_active = models.BooleanField(
_("active"),
default=True,
help_text=_(
"The groups this user belongs to. A user will get all permissions "
"granted to each of their groups."
"Designates whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
related_name="users",
)
ban_groups = models.ManyToManyField(
BanGroup,
verbose_name=_("ban groups"),
through="UserBan",
help_text=_("The bans this user has received."),
related_name="users",
date_joined = models.DateField(_("date joined"), auto_now_add=True)
last_update = models.DateTimeField(_("last update"), auto_now=True)
is_superuser = models.BooleanField(
_("superuser"),
default=False,
help_text=_("Designates whether this user is a superuser. "),
)
groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
home = models.OneToOneField(
"SithFile",
related_name="home_of",
@ -318,20 +401,18 @@ class User(AbstractUser):
objects = CustomUserManager()
USERNAME_FIELD = "username"
def __str__(self):
return self.get_display_name()
def save(self, *args, **kwargs):
adding = self._state.adding
with transaction.atomic():
if not adding:
if self.id:
old = User.objects.filter(id=self.id).first()
if old and old.username != self.username:
self._change_username(self.username)
super().save(*args, **kwargs)
if adding:
# All users are in the public group.
self.groups.add(settings.SITH_GROUP_PUBLIC_ID)
def get_absolute_url(self) -> str:
return reverse("core:user_profile", kwargs={"user_id": self.pk})
@ -341,23 +422,22 @@ class User(AbstractUser):
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
).exists()
def has_module_perms(self, package_name: str) -> bool:
return self.is_active
def has_perm(self, perm: str, obj: Any = None) -> bool:
return self.is_active and self.is_superuser
@cached_property
def was_subscribed(self) -> bool:
if "is_subscribed" in self.__dict__ and self.is_subscribed:
# if the user is currently subscribed, he is an old subscriber too
# if the property has already been cached, avoid another request
return True
return self.subscriptions.exists()
@cached_property
def is_subscribed(self) -> bool:
if "was_subscribed" in self.__dict__ and not self.was_subscribed:
# if the user never subscribed, he cannot be a subscriber now.
# if the property has already been cached, avoid another request
return False
return self.subscriptions.filter(
s = self.subscriptions.filter(
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
).exists()
)
return s.exists()
@cached_property
def account_balance(self):
@ -386,10 +466,26 @@ class User(AbstractUser):
raise ValueError("You must either provide the id or the name of the group")
if group is None:
return False
if group.id == settings.SITH_GROUP_PUBLIC_ID:
return True
if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed
if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:
return self.was_subscribed
if group.id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
if group.is_meta:
# check if this group is associated with a club
group.__class__ = MetaGroup
club = group.associated_club
if club is None:
return False
membership = club.get_membership_for(self)
if membership is None:
return False
if group.name.endswith(settings.SITH_MEMBER_SUFFIX):
return True
return membership.role > settings.SITH_MAXIMUM_FREE_ROLE
return group in self.cached_groups
@property
@ -414,8 +510,32 @@ class User(AbstractUser):
return any(g.id == root_id for g in self.cached_groups)
@cached_property
def is_board_member(self) -> bool:
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
def is_board_member(self):
main_club = settings.SITH_MAIN_CLUB["unix_name"]
return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
@cached_property
def can_read_subscription_history(self):
if self.is_root or self.is_board_member:
return True
from club.models import Club
for club in Club.objects.filter(
id__in=settings.SITH_CAN_READ_SUBSCRIPTION_HISTORY
):
if club in self.clubs_with_rights:
return True
return False
@cached_property
def can_create_subscription(self):
from club.models import Club
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
if club in self.clubs_with_rights:
return True
return False
@cached_property
def is_launderette_manager(self):
@ -430,12 +550,12 @@ class User(AbstractUser):
)
@cached_property
def is_banned_alcohol(self) -> bool:
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
def is_banned_alcohol(self):
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
@cached_property
def is_banned_counter(self) -> bool:
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
def is_banned_counter(self):
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
@cached_property
def age(self) -> int:
@ -479,6 +599,11 @@ class User(AbstractUser):
"date_of_birth": self.date_of_birth,
}
def get_full_name(self):
"""Returns the first_name plus the last_name, with a space in between."""
full_name = "%s %s" % (self.first_name, self.last_name)
return full_name.strip()
def get_short_name(self):
"""Returns the short name for the user."""
if self.nick_name:
@ -494,6 +619,14 @@ class User(AbstractUser):
return "%s (%s)" % (self.get_full_name(), self.nick_name)
return self.get_full_name()
def get_age(self):
"""Returns the age."""
today = timezone.now()
born = self.date_of_birth
return (
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
)
def get_family(
self,
godfathers_depth: NonNegativeInt = 4,
@ -656,6 +789,14 @@ class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
@property
def can_create_subscription(self):
return False
@property
def can_read_subscription_history(self):
return False
@property
def was_subscribed(self):
return False
@ -729,52 +870,6 @@ class AnonymousUser(AuthAnonymousUser):
return _("Visitor")
class UserBan(models.Model):
"""A ban of a user.
A user can be banned for a specific reason, for a specific duration.
The expiration date is indicative, and the ban should be removed manually.
"""
ban_group = models.ForeignKey(
BanGroup,
verbose_name=_("ban type"),
related_name="user_bans",
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User, verbose_name=_("user"), related_name="bans", on_delete=models.CASCADE
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
expires_at = models.DateTimeField(
_("expires at"),
null=True,
blank=True,
help_text=_(
"When the ban should be removed. "
"Currently, there is no automatic removal, so this is purely indicative. "
"Automatic ban removal may be implemented later on."
),
)
reason = models.TextField(_("reason"))
class Meta:
verbose_name = _("user ban")
verbose_name_plural = _("user bans")
constraints = [
models.UniqueConstraint(
fields=["ban_group", "user"], name="unique_ban_type_per_user"
),
models.CheckConstraint(
check=Q(expires_at__gte=F("created_at")),
name="user_ban_end_after_start",
),
]
def __str__(self):
return f"Ban of user {self.user.id}"
class Preferences(models.Model):
user = models.OneToOneField(
User, related_name="_preferences", on_delete=models.CASCADE
@ -887,17 +982,19 @@ class SithFile(models.Model):
if copy_rights:
self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
.first()
.users.all()
):
Notification(
user=user,
user=u,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool:
def is_owned_by(self, user):
if user.is_anonymous:
return False
if user.is_root:
@ -912,7 +1009,7 @@ class SithFile(models.Model):
return True
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"):
return user.can_view(self.profile_of)
if hasattr(self, "avatar_of"):
@ -959,11 +1056,17 @@ class SithFile(models.Model):
if self.is_folder:
if self.file:
try:
Image.open(BytesIO(self.file.read()))
except Image.UnidentifiedImageError as e:
raise ValidationError(
_("This is not a valid folder thumbnail")
) from e
import imghdr
if imghdr.what(None, self.file.read()) not in [
"gif",
"png",
"jpeg",
]:
self.file.delete()
self.file = None
except: # noqa E722 I don't know the exception that can be raised
self.file = None
self.mime_type = "inode/directory"
if self.is_file and (self.file is None or self.file == ""):
raise ValidationError(_("You must provide a file"))

View File

@ -4,7 +4,6 @@ from typing import Annotated
from annotated_types import MinLen
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Q
from django.urls import reverse
from django.utils.text import slugify
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema
@ -38,13 +37,13 @@ class UserProfileSchema(ModelSchema):
@staticmethod
def resolve_profile_url(obj: User) -> str:
return reverse("core:user_profile", kwargs={"user_id": obj.pk})
return obj.get_absolute_url()
@staticmethod
def resolve_profile_pict(obj: User) -> str:
if obj.profile_pict_id is None:
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):

View File

@ -1,7 +1,5 @@
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
Alpine.plugin(sort);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {

View File

@ -67,8 +67,6 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
remove_button: {
title: gettext("Remove"),
},
// biome-ignore lint/style/useNamingConvention: this is required by the api
restore_on_backspace: {},
},
persist: false,
maxItems: this.node.multiple ? this.max : 1,
@ -105,12 +103,6 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
/**
* A cache of researches that have been made using this input.
* For each record, the key is the user's query and the value
* is the list of results sent back by the server.
*/
protected cache = {} as Record<string, TomOption[]>;
protected abstract valueField: string;
protected abstract labelField: string;
@ -143,13 +135,7 @@ export abstract class AjaxSelect extends AutoCompleteSelectBase {
this.widget.clearOptions();
}
// Check in the cache if this query has already been typed
// and do an actual HTTP request only if the result isn't cached
let resp = this.cache[query];
if (!resp) {
resp = await this.search(query);
this.cache[query] = resp;
}
const resp = await this.search(query);
if (this.filter) {
callback(this.filter(resp), []);

View File

@ -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);
}
});

View File

@ -1,11 +1,3 @@
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 });

View File

@ -0,0 +1,101 @@
import { paginated } from "#core:utils/api";
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import { picturesFetchPictures } from "#openapi";
/**
* @typedef UserProfile
* @property {number} id
* @property {string} first_name
* @property {string} last_name
* @property {string} nick_name
* @property {string} display_name
* @property {string} profile_url
* @property {string} profile_pict
*/
/**
* @typedef Picture
* @property {number} id
* @property {string} name
* @property {number} size
* @property {string} date
* @property {UserProfile} owner
* @property {string} full_size_url
* @property {string} compressed_url
* @property {string} thumb_url
* @property {string} album
* @property {boolean} is_moderated
* @property {boolean} asked_for_removal
*/
/**
* @typedef PicturePageConfig
* @property {number} userId Id of the user to get the pictures from
**/
/**
* Load user picture page with a nice download bar
* @param {PicturePageConfig} config
**/
window.loadPicturePage = (config) => {
document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", () => ({
isDownloading: false,
loading: true,
pictures: [],
albums: {},
async init() {
this.pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { users_identified: [config.userId] },
});
this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]) {
acc[picture.album] = [];
}
acc[picture.album].push(picture);
return acc;
}, {});
this.loading = false;
},
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const incrementProgressBar = () => {
bar.value++;
};
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: interpolate(
gettext("pictures.%(extension)s"),
{ extension: "zip" },
true,
),
types: {},
excludeAcceptAllOption: false,
});
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.pictures.map((p) => {
const imgName = `${p.album}/IMG_${p.date.replaceAll(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),
);
await zipWriter.close();
this.isDownloading = false;
},
}));
});
};

View File

@ -14,7 +14,6 @@ export interface PaginatedRequest {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size?: number;
};
url: string;
}
type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
@ -23,15 +22,12 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
// TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost.
/**
* Load complete dataset from paginated routes.
*/
export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,
): Promise<T[]> => {
) => {
const maxPerPage = 199;
const queryParams = options ?? ({} as PaginatedRequest);
const queryParams = options ?? {};
queryParams.query = queryParams.query ?? {};
queryParams.query.page_size = maxPerPage;
queryParams.query.page = 1;
@ -56,7 +52,6 @@ export const paginated = async <T>(
interface Request {
client?: Client;
url: string;
}
interface InterceptorOptions {

View File

@ -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}`;
},
};

View File

@ -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;

View File

@ -6,16 +6,7 @@
**/
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
return (component: CustomElementConstructor) => {
try {
window.customElements.define(name, component, options);
} catch (e) {
if (e instanceof DOMException) {
// biome-ignore lint/suspicious/noConsole: it's handy to troobleshot
console.warn(e.message);
return;
}
throw e;
}
window.customElements.define(name, component, options);
};
}

View File

@ -24,17 +24,9 @@ $black-color: hsl(0, 0%, 17%);
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);
$discordblurple: #7289da;
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
$githubblack: rgb(22, 22, 20);
$shadow-color: rgb(223, 223, 223);
$background-button-color: hsl(0, 0%, 95%);
$deepblue: #354a5f;
@mixin shadow {
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
}
$deepblue: #354a5f;

View File

@ -1,27 +1,11 @@
.ts-wrapper.multi .ts-control {
min-width: calc(100% - 0.2rem);
}
/* This also requires ajax-select-index.css */
.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 {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
overflow: hidden;
img {
height: 40px;
@ -32,44 +16,19 @@
}
}
.ts-wrapper {
margin: 5px;
}
.ts-wrapper.single {
> .ts-control {
box-shadow: none;
max-width: 300px;
background-color: var(--nf-input-background-color);
&::after {
content: none;
}
}
> .ts-dropdown {
max-width: 300px;
}
}
.ts-wrapper 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;
}
width: 263px; // same length as regular text inputs
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
border-left: 1px solid #aaa;
}
.ts-wrapper.multi.has-items .ts-control {
padding: calc(var(--nf-input-size) * 0.65);
display: flex;
gap: calc(var(--nf-input-size) / 3);
.ts-wrapper.multi .ts-control {
[data-value],
[data-value].active {
background-image: none;
@ -78,17 +37,19 @@
border: 1px solid #aaa;
border-radius: 4px;
display: inline-block;
margin-left: 5px;
margin-top: 5px;
margin-bottom: 5px;
padding-right: 10px;
padding-left: 10px;
text-shadow: none;
box-shadow: none;
.remove {
vertical-align: baseline;
}
}
}
.ts-wrapper.focus .ts-control {
box-shadow: none;
}
.ts-dropdown {
.option.active {
background-color: #e5eafa;
color: inherit;
}
}

View File

@ -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
}
}

View File

@ -1,5 +0,0 @@
/*--------------------------MEDIA QUERY HELPERS------------------------*/
$small-devices: 576px;
$medium-devices: 768px;
$large-devices: 992px;

View File

@ -1,732 +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: none;
background-position: 0 0;
background-color: var(--nf-input-focus-border-color);
&::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);
}
}

View File

@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
flex-direction: row;
gap: 10px;
> a {
color: $text-color!important;
>a {
color: $text-color;
}
&:hover>a {
@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d;
}
>input[type=text] {
box-sizing: border-box;
max-width: 100%;
width: 100%;
min-width: unset;
border: unset;
height: 35px;
border-radius: 5px;
font-size: .9em;

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,3 @@
@import "core/static/core/colors";
main {
box-sizing: border-box;
display: flex;
@ -71,7 +69,7 @@ main {
border-radius: 50%;
justify-content: center;
align-items: center;
background-color: $primary-neutral-light-color;
background-color: #f2f2f2;
> span {
font-size: small;

View File

@ -1,9 +1,26 @@
@media (max-width: 750px) {
.title {
text-align: center;
}
}
.field-error {
height: auto !important;
> ul {
list-style-type: none;
margin: 0;
color: indianred;
> li {
text-align: left !important;
line-height: normal;
margin-top: 5px;
}
}
}
.profile {
&-visible {
display: flex;
@ -70,7 +87,11 @@
max-height: 100%;
}
> p {
> i {
font-size: 32px;
}
>p {
text-align: left !important;
width: 100% !important;
}
@ -86,6 +107,16 @@
> div {
max-width: 100%;
> input {
font-weight: normal;
cursor: pointer;
text-align: left !important;
}
> button {
min-width: 30%;
}
@media (min-width: 750px) {
height: auto;
align-items: center;
@ -93,8 +124,8 @@
overflow: hidden;
> input {
width: 70%;
font-size: .6em;
&::file-selector-button {
height: 30px;
}
@ -136,7 +167,7 @@
max-width: 100%;
}
> * {
>* {
width: 100%;
max-width: 300px;
@ -150,22 +181,45 @@
}
&-content {
> * {
>* {
box-sizing: border-box;
text-align: left !important;
line-height: 40px;
max-width: 100%;
width: 100%;
height: 40px;
margin: 0;
> * {
>* {
text-align: left !important;
}
}
}
textarea {
height: 7rem;
}
.final-actions {
text-align: center;
>textarea {
height: 120px;
min-height: 40px;
min-width: 300px;
max-width: 300px;
line-height: initial;
@media (max-width: 750px) {
max-width: 100%;
}
}
>input[type="file"] {
font-size: small;
line-height: 30px;
}
>input[type="checkbox"] {
width: 20px;
height: 20px;
margin: 0;
float: left;
}
}
}
}

View File

@ -108,8 +108,7 @@
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</div>
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
<a href="https://discord.gg/XK9WfPsUFm" target="_link">
{% trans %}Site created by the IT Department of the AE{% endtrans %}
</a>
{% endblock %}
@ -125,14 +124,15 @@
navbar.style.setProperty("display", current === "none" ? "block" : "none");
}
document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
return;
$(document).keydown(function (e) {
if ($(e.target).is('input')) { return }
if ($(e.target).is('textarea')) { 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>
{% endblock %}
</body>

View File

@ -1,40 +1,19 @@
{% extends "core/base.jinja" %}
{# if the template context has the `object_name` variable,
then this one will be used in the page title,
instead of the result of `str(object)` #}
{% if object and not object_name %}
{% set object_name=object %}
{% endif %}
{% block title %}
{% if object_name %}
{% trans name=object_name %}Edit {{ name }}{% endtrans %}
{% if object %}
{% trans obj=object %}Edit {{ obj }}{% endtrans %}
{% else %}
{% trans %}Save{% endtrans %}
{% endif %}
{% endblock %}
{% block content %}
{% if object_name %}
<h2>{% trans name=object_name %}Edit {{ name }}{% endtrans %}</h2>
{% if object %}
<h2>{% trans obj=object %}Edit {{ obj }}{% endtrans %}</h2>
{% else %}
<h2>{% trans %}Save{% endtrans %}</h2>
{% endif %}
{% if messages %}
<div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
<span class="alert-main">
{% for message in messages %}
{% if message.level_tag == "success" %}
{{ message }}
{% endif %}
{% endfor %}
</span>
<span class="clickable" @click="show_alert = false">
<i class="fa fa-close"></i>
</span>
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}

View File

@ -57,4 +57,13 @@
{% endblock %}
{% endif %}
{% block script %}
{{ super() }}
{% if popup %}
<script>
parent.$(".choose_file_widget").css("height", "75%");
</script>
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -39,6 +39,14 @@
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endmacro %}
{% macro fb_quick(news) -%}
<a rel="nofollow" target="#" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}" class="fb fa-brands fa-facebook fa-2x"></a>
{%- endmacro %}
{% macro tweet_quick(news) -%}
<a rel="nofollow" target="#" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}" class="twitter fa-brands fa-twitter-square fa-2x"></a>
{%- endmacro %}
{% macro user_mini_profile(user) %}
<div class="user_mini_profile">
<div class="user_mini_profile_infos">
@ -52,18 +60,13 @@
{% endif %}
{% if user.date_of_birth %}
<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>
{% endif %}
</div>
{% if user.promo and user.promo_has_logo() %}
<div class="user_mini_profile_promo">
<img
src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
title="Promo {{ user.promo }}"
alt="Promo {{ user.promo }}"
class="promo_pict"
/>
<img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" />
</div>
{% endif %}
</div>
@ -71,11 +74,8 @@
{% if user.profile_pict %}
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
{% else %}
<img
src="{{ static('core/img/unknown.jpg') }}"
alt="{% trans %}Profile{% endtrans %}"
title="{% trans %}Profile{% endtrans %}"
/>
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
title="{% trans %}Profile{% endtrans %}" />
{% endif %}
</div>
</div>
@ -132,7 +132,7 @@
nb_page (str): call to a javascript function or variable returning
the maximum number of pages to paginate
#}
<nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
<nav class="pagination" x-show="{{ nb_pages }} > 1">
{# Adding the prevent here is important, because otherwise,
clicking on the pagination buttons could submit the picture management form
and reload the page #}
@ -170,12 +170,12 @@
{% endmacro %}
{% 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
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:
current_page (django.core.paginator.Page): the current page object
@ -247,9 +247,9 @@
{% macro select_all_checkbox(form_id) %}
<script type="text/javascript">
function checkbox_{{form_id}}(value) {
const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
for (let element of inputs){
if (element.type === "checkbox"){
list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
for (let element of list){
if (element.type == "checkbox"){
element.checked = value;
}
}
@ -258,65 +258,3 @@
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% endmacro %}
{% macro tabs(tab_list, attrs = "") %}
{# Tab component
Parameters:
tab_list: list[tuple[str, str]] The list of tabs to display.
Each element of the list is a tuple which first element
is the title of the tab and the second element its content
attrs: str Additional attributes to put on the enclosing div
Example:
A basic usage would be as follow :
{{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
If you want to display more complex logic, you can define macros
and use those macros in parameters :
{{ tabs([("title", my_macro())]) }}
It's also possible to get and set the currently selected tab using Alpine.
Here, the title of the currently selected tab will be displayed.
Moreover, on page load, the tab will be opened on "tab 2".
<div x-data="{current_tab: 'tab 2'}">
<p x-text="current_tab"></p>
{{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
</div>
If you want to have translated tab titles, you can enclose the macro call
in a with block :
{% with title=_("title"), content=_("Content") %}
{{ tabs([(tab1, content)]) }}
{% endwith %}
#}
<div
class="tabs shadow"
x-data="{selected: '{{ tab_list[0][0] }}'}"
x-modelable="selected"
{{ attrs }}
>
<div class="tab-headers">
{% for title, _ in tab_list %}
<button
class="tab-header clickable"
:class="{active: selected === '{{ title }}'}"
@click="selected = '{{ title }}'"
>
{{ title }}
</button>
{% endfor %}
</div>
<div class="tab-content">
{% for title, content in tab_list %}
<section x-show="selected === '{{ title }}'">
{{ content }}
</section>
{% endfor %}
</div>
</div>
{% endmacro %}

View File

@ -3,18 +3,17 @@
{% macro page_history(page) %}
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
<ul>
{% set page_name = page.get_full_name() %}
{%- for rev in page.revisions.order_by("-date").select_related("author") -%}
<li>
{% if loop.first %}
<a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
{% endif %}
{{ user_profile_link(rev.author) }} -
{{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
</li>
{%- endfor -%}
{% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
{% if loop.index < 2 %}
<li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
{{ user_profile_link(page.revisions.last().author) }} -
{{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
{% else %}
<li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
{{ user_profile_link(r.author) }} -
{{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endmacro %}

View 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 %}

View File

@ -30,7 +30,7 @@
{% if m.can_be_edited_by(user) %}
<td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
{% endif %}
{% if user.has_perm("club.delete_membership") %}
{% if user.is_root %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %}
</tr>
@ -59,7 +59,7 @@
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td>
{% if user.has_perm("club.delete_membership") %}
{% if user.is_root %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %}
</tr>

View File

@ -166,7 +166,7 @@
</div>
{% endif %}
<br>
{% if profile.was_subscribed and (user == profile or user.has_perm("subscription.view_subscription")) %}
{% if profile.was_subscribed and (user == profile or user.can_read_subscription_history)%}
<div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
<div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text">
@ -197,9 +197,9 @@
</table>
</div>
</div>
<hr>
{% endif %}
<hr>
<div>
{% if user.is_root or user.is_board_member %}
<form class="form-gifts" action="{{ url('core:user_gift_create', user_id=profile.id) }}" method="post">
@ -244,30 +244,27 @@
{% block script %}
{{ super() }}
<script>
// Image selection
for (const img of document.querySelectorAll("#small_pictures img")){
img.addEventListener("click", (e) => {
const displayed = document.querySelector("#big_picture img");
displayed.src = e.target.src;
displayed.alt = e.target.alt;
displayed.title = e.target.title;
})
}
let keys = [];
const pattern = "71,85,89,71,85,89";
document.addEventListener("keydown", (e) => {
keys.push(e.keyCode);
if (keys.toString() === pattern) {
keys = [];
document.querySelector("#big_picture img").src = "{{ static('core/img/yug.jpg') }}";
}
if (keys.length === 6) {
keys.shift();
}
$(function () {
var keys = [];
var pattern = "71,85,89,71,85,89";
$(document).keydown(function (e) {
keys.push(e.keyCode);
if (keys.toString() == pattern) {
keys = [];
$("#big_picture img").attr("src", "{{ static('core/img/yug.jpg') }}");
}
if (keys.length == 6) {
keys.shift();
}
});
});
$(function () {
$("#small_pictures img").click(function () {
$("#big_picture img").attr("src", $(this)[0].src);
$("#big_picture img").attr("alt", $(this)[0].alt);
$("#big_picture img").attr("title", $(this)[0].title);
})
});
$(function () {
$("#drop_gifts").accordion({
heightStyle: "content",

Some files were not shown because too many files have changed in this diff Show More