mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-13 20:48:06 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b8670188 |
@@ -12,7 +12,7 @@ runs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install apt packages
|
- name: Install apt packages
|
||||||
if: ${{ inputs.full == 'true' }}
|
if: ${{ inputs.full == 'true' }}
|
||||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||||
with:
|
with:
|
||||||
packages: gettext
|
packages: gettext
|
||||||
version: 1.0 # increment to reset cache
|
version: 1.0 # increment to reset cache
|
||||||
@@ -23,29 +23,26 @@ runs:
|
|||||||
with:
|
with:
|
||||||
redis-version: "7.x"
|
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@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v8.1.0
|
|
||||||
with:
|
|
||||||
version: "0.11.8"
|
|
||||||
enable-cache: false
|
|
||||||
cache-dependency-glob: "uv.lock"
|
|
||||||
|
|
||||||
- name: Restore cached virtualenv
|
- name: Restore cached virtualenv
|
||||||
uses: actions/cache@v5
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
|
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||||
path: .venv
|
path: .venv
|
||||||
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
|
|
||||||
uv-${{ runner.os }}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --locked
|
run: uv sync
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Xapian
|
- name: Install Xapian
|
||||||
@@ -53,6 +50,15 @@ runs:
|
|||||||
run: uv run ./manage.py install_xapian
|
run: uv run ./manage.py install_xapian
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
# compiling xapian accounts for almost the entirety of the virtualenv setup,
|
||||||
|
# so we save the virtual environment only on workflows where it has been installed
|
||||||
|
- name: Save cached virtualenv
|
||||||
|
if: ${{ inputs.full == 'true' }}
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||||
|
path: .venv
|
||||||
|
|
||||||
- name: Compile gettext messages
|
- name: Compile gettext messages
|
||||||
if: ${{ inputs.full == 'true' }}
|
if: ${{ inputs.full == 'true' }}
|
||||||
run: uv run ./manage.py compilemessages
|
run: uv run ./manage.py compilemessages
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ jobs:
|
|||||||
name: Launch pre-commits checks (ruff)
|
name: Launch pre-commits checks (ruff)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
- uses: pre-commit/action@v3.0.1
|
- uses: pre-commit/action@v3.0.1
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
pytest-mark: [not slow]
|
pytest-mark: [not slow]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
with:
|
with:
|
||||||
full: true
|
full: true
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
uv run coverage report
|
uv run coverage report
|
||||||
uv run coverage html
|
uv run coverage html
|
||||||
- name: Archive code coverage results
|
- name: Archive code coverage results
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage-report-${{ matrix.pytest-mark }}
|
name: coverage-report-${{ matrix.pytest-mark }}
|
||||||
path: coverage_report
|
path: coverage_report
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: SSH Remote Commands
|
- name: SSH Remote Commands
|
||||||
uses: appleboy/ssh-action@v1.2.5
|
uses: appleboy/ssh-action@v1.1.0
|
||||||
with:
|
with:
|
||||||
# Proxy
|
# Proxy
|
||||||
proxy_host : ${{secrets.PROXY_HOST}}
|
proxy_host : ${{secrets.PROXY_HOST}}
|
||||||
@@ -29,6 +29,8 @@ jobs:
|
|||||||
username : ${{secrets.USER}}
|
username : ${{secrets.USER}}
|
||||||
key: ${{secrets.KEY}}
|
key: ${{secrets.KEY}}
|
||||||
|
|
||||||
|
script_stop: true
|
||||||
|
|
||||||
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
||||||
script: |
|
script: |
|
||||||
cd ${{secrets.SITH_PATH}}
|
cd ${{secrets.SITH_PATH}}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
key: mkdocs-material-${{ env.cache_id }}
|
key: mkdocs-material-${{ env.cache_id }}
|
||||||
path: .cache
|
path: .cache
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: SSH Remote Commands
|
- name: SSH Remote Commands
|
||||||
uses: appleboy/ssh-action@v1.2.5
|
uses: appleboy/ssh-action@v1.1.0
|
||||||
with:
|
with:
|
||||||
# Proxy
|
# Proxy
|
||||||
proxy_host : ${{secrets.PROXY_HOST}}
|
proxy_host : ${{secrets.PROXY_HOST}}
|
||||||
@@ -28,6 +28,8 @@ jobs:
|
|||||||
username : ${{secrets.USER}}
|
username : ${{secrets.USER}}
|
||||||
key: ${{secrets.KEY}}
|
key: ${{secrets.KEY}}
|
||||||
|
|
||||||
|
script_stop: true
|
||||||
|
|
||||||
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
|
||||||
script: |
|
script: |
|
||||||
cd ${{secrets.SITH_PATH}}
|
cd ${{secrets.SITH_PATH}}
|
||||||
|
|||||||
+1
-28
@@ -13,10 +13,8 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.forms.models import ModelForm
|
|
||||||
from django.http import HttpRequest
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Club)
|
@admin.register(Club)
|
||||||
@@ -31,31 +29,6 @@ class ClubAdmin(admin.ModelAdmin):
|
|||||||
"page",
|
"page",
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_model(
|
|
||||||
self,
|
|
||||||
request: HttpRequest,
|
|
||||||
obj: Club,
|
|
||||||
form: ModelForm,
|
|
||||||
change: bool, # noqa: FBT001
|
|
||||||
):
|
|
||||||
super().save_model(request, obj, form, change)
|
|
||||||
if not change:
|
|
||||||
obj.create_default_roles()
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ClubRole)
|
|
||||||
class ClubRoleAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "club", "is_board", "is_presidency")
|
|
||||||
search_fields = ("name",)
|
|
||||||
autocomplete_fields = ("club",)
|
|
||||||
list_select_related = ("club",)
|
|
||||||
list_filter = (
|
|
||||||
"is_board",
|
|
||||||
"is_presidency",
|
|
||||||
("club", admin.RelatedOnlyFieldListFilter),
|
|
||||||
)
|
|
||||||
show_facets = admin.ModelAdmin.show_facets.ALWAYS
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Membership)
|
@admin.register(Membership)
|
||||||
class MembershipAdmin(admin.ModelAdmin):
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
+2
-3
@@ -37,8 +37,7 @@ class ClubController(ControllerBase):
|
|||||||
)
|
)
|
||||||
def fetch_club(self, club_id: int):
|
def fetch_club(self, club_id: int):
|
||||||
prefetch = Prefetch(
|
prefetch = Prefetch(
|
||||||
"members",
|
"members", queryset=Membership.objects.ongoing().select_related("user")
|
||||||
queryset=Membership.objects.ongoing().select_related("user", "role"),
|
|
||||||
)
|
)
|
||||||
return self.get_object_or_exception(
|
return self.get_object_or_exception(
|
||||||
Club.objects.prefetch_related(prefetch), id=club_id
|
Club.objects.prefetch_related(prefetch), id=club_id
|
||||||
@@ -60,5 +59,5 @@ class UserClubController(ControllerBase):
|
|||||||
return (
|
return (
|
||||||
Membership.objects.ongoing()
|
Membership.objects.ongoing()
|
||||||
.filter(user=user)
|
.filter(user=user)
|
||||||
.select_related("club", "user", "role")
|
.select_related("club", "user")
|
||||||
)
|
)
|
||||||
|
|||||||
+27
-18
@@ -23,12 +23,13 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
from django.conf import settings
|
||||||
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDateTime
|
from core.views.forms import SelectDateTime
|
||||||
from core.views.widgets.ajax_select import (
|
from core.views.widgets.ajax_select import (
|
||||||
@@ -214,7 +215,9 @@ class ClubOldMemberForm(forms.Form):
|
|||||||
|
|
||||||
def __init__(self, *args, user: User, club: Club, **kwargs):
|
def __init__(self, *args, user: User, club: Club, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
|
self.fields["members_old"].queryset = (
|
||||||
|
Membership.objects.ongoing().filter(club=club).editable_by(user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClubMemberForm(forms.ModelForm):
|
class ClubMemberForm(forms.ModelForm):
|
||||||
@@ -232,14 +235,19 @@ class ClubMemberForm(forms.ModelForm):
|
|||||||
self.request_user = request_user
|
self.request_user = request_user
|
||||||
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["role"].queryset = self.available_roles
|
self.fields["role"].required = True
|
||||||
|
self.fields["role"].choices = [
|
||||||
|
(value, name)
|
||||||
|
for value, name in settings.SITH_CLUB_ROLES.items()
|
||||||
|
if value <= self.max_available_role
|
||||||
|
]
|
||||||
self.instance.club = club
|
self.instance.club = club
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available_roles(self) -> QuerySet[ClubRole]:
|
def max_available_role(self):
|
||||||
"""The roles that will be obtainable with this form."""
|
"""The greatest role that will be obtainable with this form."""
|
||||||
# this is unreachable, because it will be overridden by subclasses
|
# this is unreachable, because it will be overridden by subclasses
|
||||||
return ClubRole.objects.none() # pragma: no cover
|
return -1 # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class ClubAddMemberForm(ClubMemberForm):
|
class ClubAddMemberForm(ClubMemberForm):
|
||||||
@@ -250,22 +258,21 @@ class ClubAddMemberForm(ClubMemberForm):
|
|||||||
widgets = {"user": AutoCompleteSelectUser}
|
widgets = {"user": AutoCompleteSelectUser}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def available_roles(self):
|
def max_available_role(self):
|
||||||
"""The roles that will be obtainable with this form.
|
"""The greatest role that will be obtainable with this form.
|
||||||
|
|
||||||
Admins and the club president can attribute any role.
|
Admins and the club president can attribute any role.
|
||||||
Board members can attribute roles lower than their own.
|
Board members can attribute roles lower than their own.
|
||||||
Other users cannot attribute roles with this form
|
Other users cannot attribute roles with this form
|
||||||
"""
|
"""
|
||||||
qs = self.club.roles.filter(is_active=True)
|
|
||||||
if self.request_user.has_perm("club.add_membership"):
|
if self.request_user.has_perm("club.add_membership"):
|
||||||
return qs.all()
|
return settings.SITH_CLUB_ROLES_ID["President"]
|
||||||
membership = self.request_user_membership
|
membership = self.request_user_membership
|
||||||
if membership is None or not membership.role.is_board:
|
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
||||||
return ClubRole.objects.none()
|
return -1
|
||||||
if membership.role.is_presidency:
|
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
|
||||||
return qs.all()
|
return membership.role
|
||||||
return qs.above_instance(membership.role)
|
return membership.role - 1
|
||||||
|
|
||||||
def clean_user(self):
|
def clean_user(self):
|
||||||
"""Check that the user is not trying to add a user already in the club.
|
"""Check that the user is not trying to add a user already in the club.
|
||||||
@@ -289,11 +296,13 @@ class JoinClubForm(ClubMemberForm):
|
|||||||
|
|
||||||
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
||||||
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
||||||
|
# this form doesn't manage the user who will join the club,
|
||||||
|
# so we must set this here to avoid errors
|
||||||
self.instance.user = self.request_user
|
self.instance.user = self.request_user
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def available_roles(self):
|
def max_available_role(self):
|
||||||
return self.club.roles.filter(is_board=False, is_active=True)
|
return settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Check that the user is subscribed and isn't already in the club."""
|
"""Check that the user is subscribed and isn't already in the club."""
|
||||||
|
|||||||
@@ -2,15 +2,12 @@
|
|||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.db.models.functions.datetime
|
import django.db.models.functions.datetime
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.db.migrations.state import StateApps
|
from django.db.migrations.state import StateApps
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
|
|
||||||
# Before the club role rework, the maximum free role
|
|
||||||
# was the hardcoded highest non-board role
|
|
||||||
MAXIMUM_FREE_ROLE = 1
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_meta_groups(apps: StateApps, schema_editor):
|
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||||
"""Attach the existing meta groups to the clubs.
|
"""Attach the existing meta groups to the clubs.
|
||||||
@@ -49,7 +46,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
|
|||||||
).select_related("user")
|
).select_related("user")
|
||||||
club.members_group.users.set([m.user for m in memberships])
|
club.members_group.users.set([m.user for m in memberships])
|
||||||
club.board_group.users.set(
|
club.board_group.users.set(
|
||||||
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
|
[
|
||||||
|
m.user
|
||||||
|
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
# Generated by Django 5.2.3 on 2025-06-21 21:59
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.db.migrations.state import StateApps
|
|
||||||
from django.db.models import Case, When
|
|
||||||
|
|
||||||
PRESIDENCY_ROLES = [10, 9]
|
|
||||||
MAXIMUM_FREE_ROLE = 1
|
|
||||||
SITH_CLUB_ROLES = {
|
|
||||||
10: "Président⸱e",
|
|
||||||
9: "Vice-Président⸱e",
|
|
||||||
7: "Trésorier⸱e",
|
|
||||||
5: "Responsable communication",
|
|
||||||
4: "Secrétaire",
|
|
||||||
3: "Responsable info",
|
|
||||||
2: "Membre du bureau",
|
|
||||||
1: "Membre actif⸱ve",
|
|
||||||
0: "Curieux⸱euse",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_roles(apps: StateApps, schema_editor):
|
|
||||||
ClubRole = apps.get_model("club", "ClubRole")
|
|
||||||
Membership = apps.get_model("club", "Membership")
|
|
||||||
|
|
||||||
updates = []
|
|
||||||
for club_id, role in Membership.objects.values_list("club", "role").distinct():
|
|
||||||
new_role = ClubRole.objects.create(
|
|
||||||
name=SITH_CLUB_ROLES[role],
|
|
||||||
is_board=role > MAXIMUM_FREE_ROLE,
|
|
||||||
is_presidency=role in PRESIDENCY_ROLES,
|
|
||||||
club_id=club_id,
|
|
||||||
order=max(SITH_CLUB_ROLES) - role,
|
|
||||||
)
|
|
||||||
updates.append(When(role=role, then=new_role.id))
|
|
||||||
# all updates must happen at the same time
|
|
||||||
# otherwise, the 10 first created ClubRole would be
|
|
||||||
# re-modified after their initial creation, and it would
|
|
||||||
# result in an incoherent state.
|
|
||||||
# To avoid that, all updates are wrapped in a single giant Case(When) statement
|
|
||||||
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
|
|
||||||
Membership.objects.update(role=Case(*updates))
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
|
|
||||||
("core", "0047_alter_notification_date_alter_notification_type"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="club",
|
|
||||||
name="page",
|
|
||||||
field=models.OneToOneField(
|
|
||||||
blank=True,
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name="club",
|
|
||||||
to="core.page",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ClubRole",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"order",
|
|
||||||
models.PositiveIntegerField(
|
|
||||||
db_index=True, editable=False, verbose_name="order"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"club",
|
|
||||||
models.ForeignKey(
|
|
||||||
help_text="The club with which this role is associated",
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="roles",
|
|
||||||
to="club.club",
|
|
||||||
verbose_name="club",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=50, verbose_name="name")),
|
|
||||||
(
|
|
||||||
"description",
|
|
||||||
models.TextField(
|
|
||||||
default="", blank=True, verbose_name="description"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"is_board",
|
|
||||||
models.BooleanField(default=False, verbose_name="Board role"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"is_presidency",
|
|
||||||
models.BooleanField(default=False, verbose_name="Presidency role"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"is_active",
|
|
||||||
models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text=(
|
|
||||||
"If the role is inactive, people joining the club "
|
|
||||||
"won't be able to get it."
|
|
||||||
),
|
|
||||||
verbose_name="is active",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"ordering": ("order",),
|
|
||||||
"verbose_name": "club role",
|
|
||||||
"verbose_name_plural": "club roles",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="club",
|
|
||||||
name="board_group",
|
|
||||||
field=models.OneToOneField(
|
|
||||||
editable=False,
|
|
||||||
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(
|
|
||||||
editable=False,
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name="club",
|
|
||||||
to="core.group",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="clubrole",
|
|
||||||
constraint=models.CheckConstraint(
|
|
||||||
condition=models.Q(
|
|
||||||
("is_presidency", False), ("is_board", True), _connector="OR"
|
|
||||||
),
|
|
||||||
name="clubrole_presidency_implies_board",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
|
|
||||||
# because Postgres migrations run in a single transaction,
|
|
||||||
# we cannot change the actual values of Membership.role
|
|
||||||
# and apply the FOREIGN KEY constraint in the same migration.
|
|
||||||
# The constraint is created in the next migration
|
|
||||||
]
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Generated by Django 5.2.3 on 2025-09-27 09:57
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("club", "0015_clubrole_alter_membership_role")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
# because Postgres migrations run in a single transaction,
|
|
||||||
# we cannot change the actual values of Membership.role
|
|
||||||
# and apply the FOREIGN KEY constraint in the same migration.
|
|
||||||
# The data migration was made in the previous migration.
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="membership",
|
|
||||||
name="role",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name="members",
|
|
||||||
to="club.clubrole",
|
|
||||||
verbose_name="role",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+29
-163
@@ -28,15 +28,15 @@ from typing import Iterable, Self
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.core.validators import RegexValidator, validate_email
|
from django.core.validators import RegexValidator, validate_email
|
||||||
from django.db import ProgrammingError, models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Exists, F, OuterRef, Q
|
from django.db.models import Exists, F, OuterRef, Q, Value
|
||||||
|
from django.db.models.functions import Greatest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from ordered_model.models import OrderedModel
|
|
||||||
|
|
||||||
from core.fields import ResizedImageField
|
from core.fields import ResizedImageField
|
||||||
from core.models import Group, Notification, Page, SithFile, User
|
from core.models import Group, Notification, Page, SithFile, User
|
||||||
@@ -89,13 +89,13 @@ class Club(models.Model):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
page = models.OneToOneField(
|
page = models.OneToOneField(
|
||||||
Page, related_name="club", blank=True, on_delete=models.PROTECT
|
Page, related_name="club", blank=True, on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
members_group = models.OneToOneField(
|
members_group = models.OneToOneField(
|
||||||
Group, related_name="club", on_delete=models.PROTECT, editable=False
|
Group, related_name="club", on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
board_group = models.OneToOneField(
|
board_group = models.OneToOneField(
|
||||||
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
|
Group, related_name="club_board", on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ClubQuerySet.as_manager()
|
objects = ClubQuerySet.as_manager()
|
||||||
@@ -138,7 +138,9 @@ class Club(models.Model):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def president(self) -> Membership | None:
|
def president(self) -> Membership | None:
|
||||||
"""Fetch the membership of the current president of this club."""
|
"""Fetch the membership of the current president of this club."""
|
||||||
return self.members.filter(end_date=None).order_by("role__order").first()
|
return self.members.filter(
|
||||||
|
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
||||||
|
).first()
|
||||||
|
|
||||||
def check_loop(self):
|
def check_loop(self):
|
||||||
"""Raise a validation error when a loop is found within the parent list."""
|
"""Raise a validation error when a loop is found within the parent list."""
|
||||||
@@ -183,40 +185,6 @@ class Club(models.Model):
|
|||||||
self.page.parent = self.parent.page
|
self.page.parent = self.parent.page
|
||||||
self.page.save(force_lock=True)
|
self.page.save(force_lock=True)
|
||||||
|
|
||||||
def create_default_roles(self):
|
|
||||||
"""Create some roles that should exist by default for this club.
|
|
||||||
|
|
||||||
The created roles are : president, treasurer, active member and curious.
|
|
||||||
|
|
||||||
Warnings:
|
|
||||||
When calling this method, no club must exist yet for this club.
|
|
||||||
"""
|
|
||||||
if self.roles.exists():
|
|
||||||
raise ProgrammingError(
|
|
||||||
"Default roles can be created only for clubs "
|
|
||||||
"that don't have associated roles yet"
|
|
||||||
)
|
|
||||||
# The names are written in French, because there is no gettext involved
|
|
||||||
# for strings stored in database, and the majority of users are french.
|
|
||||||
roles = [
|
|
||||||
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
|
|
||||||
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
|
|
||||||
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
|
|
||||||
ClubRole(
|
|
||||||
name="Curieux⸱euse",
|
|
||||||
description=(
|
|
||||||
"Les gens qui suivent l'activité "
|
|
||||||
"du club sans forcément y participer"
|
|
||||||
),
|
|
||||||
is_board=False,
|
|
||||||
is_presidency=False,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for i, role in enumerate(roles):
|
|
||||||
role.club = self
|
|
||||||
role.order = i
|
|
||||||
ClubRole.objects.bulk_create(roles)
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||||
self.board_group.delete()
|
self.board_group.delete()
|
||||||
self.members_group.delete()
|
self.members_group.delete()
|
||||||
@@ -240,9 +208,7 @@ class Club(models.Model):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def current_members(self) -> list[Membership]:
|
def current_members(self) -> list[Membership]:
|
||||||
return list(
|
return list(self.members.ongoing().select_related("user").order_by("-role"))
|
||||||
self.members.ongoing().select_related("user", "role").order_by("-role")
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_membership_for(self, user: User) -> Membership | None:
|
def get_membership_for(self, user: User) -> Membership | None:
|
||||||
"""Return the current membership of the given user."""
|
"""Return the current membership of the given user."""
|
||||||
@@ -254,105 +220,6 @@ class Club(models.Model):
|
|||||||
return user.is_in_group(pk=self.board_group_id)
|
return user.is_in_group(pk=self.board_group_id)
|
||||||
|
|
||||||
|
|
||||||
class ClubRole(OrderedModel):
|
|
||||||
club = models.ForeignKey(
|
|
||||||
Club,
|
|
||||||
verbose_name=_("club"),
|
|
||||||
help_text=_("The club with which this role is associated"),
|
|
||||||
related_name="roles",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
name = models.CharField(_("name"), max_length=50)
|
|
||||||
description = models.TextField(_("description"), blank=True, default="")
|
|
||||||
is_board = models.BooleanField(_("Board role"), default=False)
|
|
||||||
is_presidency = models.BooleanField(_("Presidency role"), default=False)
|
|
||||||
is_active = models.BooleanField(
|
|
||||||
_("is active"),
|
|
||||||
default=True,
|
|
||||||
help_text=_(
|
|
||||||
"If the role is inactive, people joining the club won't be able to get it."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
order_with_respect_to = "club"
|
|
||||||
|
|
||||||
class Meta(OrderedModel.Meta):
|
|
||||||
verbose_name = _("club role")
|
|
||||||
verbose_name_plural = _("club roles")
|
|
||||||
constraints = [
|
|
||||||
# presidency IMPLIES board <=> NOT presidency OR board
|
|
||||||
# cf. MT1 :)
|
|
||||||
models.CheckConstraint(
|
|
||||||
condition=Q(is_presidency=False) | Q(is_board=True),
|
|
||||||
name="clubrole_presidency_implies_board",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def get_display_name(self):
|
|
||||||
return f"{self.name} - {self.club.name}"
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse("club:club_roles", kwargs={"club_id": self.club_id})
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
errors = []
|
|
||||||
if self.is_presidency and not self.is_board:
|
|
||||||
errors.append(
|
|
||||||
ValidationError(
|
|
||||||
_(
|
|
||||||
"Role %(name)s was declared as a presidency role "
|
|
||||||
"without being a board role"
|
|
||||||
)
|
|
||||||
% {"name": self.name}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
roles = list(self.club.roles.all())
|
|
||||||
if (
|
|
||||||
self.is_board
|
|
||||||
and self.order
|
|
||||||
and any(r.order < self.order and not r.is_board for r in roles)
|
|
||||||
):
|
|
||||||
errors.append(
|
|
||||||
ValidationError(
|
|
||||||
_("Role %(role)s cannot be placed below a member role")
|
|
||||||
% {"role": self.name}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
self.is_presidency
|
|
||||||
and self.order
|
|
||||||
and any(r.order < self.order and not r.is_presidency for r in roles)
|
|
||||||
):
|
|
||||||
errors.append(
|
|
||||||
ValidationError(
|
|
||||||
_("Role %(role)s cannot be placed below a non-presidency role")
|
|
||||||
% {"role": self.name}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if errors:
|
|
||||||
raise ValidationError(errors)
|
|
||||||
return super().clean()
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
auto_order = self.order is None and self.is_board
|
|
||||||
if not auto_order:
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
return
|
|
||||||
# get the role that should be placed after the role we are dealing with.
|
|
||||||
# So, if this is role is presidency, get the first board role ;
|
|
||||||
# if it is a board role, get the first member role ;
|
|
||||||
# and if it is a member role, get nothing (OrderedModel.save will
|
|
||||||
# automatically put it in the last position anyway)
|
|
||||||
filters = {"is_board": self.is_presidency, "is_presidency": False}
|
|
||||||
next_role = self.club.roles.filter(**filters).order_by("order").first()
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
if next_role:
|
|
||||||
self.above(next_role)
|
|
||||||
|
|
||||||
|
|
||||||
class MembershipQuerySet(models.QuerySet):
|
class MembershipQuerySet(models.QuerySet):
|
||||||
def ongoing(self) -> Self:
|
def ongoing(self) -> Self:
|
||||||
"""Filter all memberships which are not finished yet."""
|
"""Filter all memberships which are not finished yet."""
|
||||||
@@ -365,10 +232,9 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
are included, even if there are no more members.
|
are included, even if there are no more members.
|
||||||
|
|
||||||
If you want to get the users who are currently in the board,
|
If you want to get the users who are currently in the board,
|
||||||
mind combining this with the [MembershipQuerySet.ongoing][]
|
mind combining this with the `ongoing` queryset method
|
||||||
queryset method
|
|
||||||
"""
|
"""
|
||||||
return self.filter(role__is_board=True)
|
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
|
||||||
def editable_by(self, user: User) -> Self:
|
def editable_by(self, user: User) -> Self:
|
||||||
"""Filter Memberships that this user can edit.
|
"""Filter Memberships that this user can edit.
|
||||||
@@ -391,16 +257,21 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
"""
|
"""
|
||||||
if user.has_perm("club.change_membership"):
|
if user.has_perm("club.change_membership"):
|
||||||
return self.all()
|
return self.all()
|
||||||
return self.ongoing().filter(
|
return self.filter(
|
||||||
Q(user=user)
|
Q(user=user)
|
||||||
| Exists(
|
| Exists(
|
||||||
Membership.objects.ongoing().filter(
|
Membership.objects.filter(
|
||||||
|
Q(
|
||||||
|
role__gt=Greatest(
|
||||||
|
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
)
|
||||||
|
),
|
||||||
user=user,
|
user=user,
|
||||||
|
end_date=None,
|
||||||
club=OuterRef("club"),
|
club=OuterRef("club"),
|
||||||
role__is_board=True,
|
|
||||||
role__order__lt=OuterRef("role__order"),
|
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
|
end_date=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def update(self, **kwargs) -> int:
|
def update(self, **kwargs) -> int:
|
||||||
@@ -470,11 +341,10 @@ class Membership(models.Model):
|
|||||||
)
|
)
|
||||||
start_date = models.DateField(_("start date"), default=timezone.now)
|
start_date = models.DateField(_("start date"), default=timezone.now)
|
||||||
end_date = models.DateField(_("end date"), null=True, blank=True)
|
end_date = models.DateField(_("end date"), null=True, blank=True)
|
||||||
role = models.ForeignKey(
|
role = models.IntegerField(
|
||||||
ClubRole,
|
_("role"),
|
||||||
verbose_name=_("role"),
|
choices=sorted(settings.SITH_CLUB_ROLES.items()),
|
||||||
related_name="members",
|
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
|
||||||
on_delete=models.PROTECT,
|
|
||||||
)
|
)
|
||||||
description = models.CharField(
|
description = models.CharField(
|
||||||
_("description"), max_length=128, null=False, blank=True
|
_("description"), max_length=128, null=False, blank=True
|
||||||
@@ -492,7 +362,7 @@ class Membership(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
return (
|
||||||
f"{self.club.name} - {self.user.username} "
|
f"{self.club.name} - {self.user.username} "
|
||||||
f"- {self.role.name} "
|
f"- {settings.SITH_CLUB_ROLES[self.role]} "
|
||||||
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -521,11 +391,7 @@ class Membership(models.Model):
|
|||||||
if user.is_root or user.is_board_member:
|
if user.is_root or user.is_board_member:
|
||||||
return True
|
return True
|
||||||
membership = self.club.get_membership_for(user)
|
membership = self.club.get_membership_for(user)
|
||||||
if not membership:
|
return membership is not None and membership.role >= self.role
|
||||||
return False
|
|
||||||
return membership.user_id == user.id or (
|
|
||||||
membership.is_board and membership.role.order < self.role.order
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self._remove_club_groups([self])
|
self._remove_club_groups([self])
|
||||||
@@ -601,7 +467,7 @@ class Membership(models.Model):
|
|||||||
group_id=membership.club.members_group_id,
|
group_id=membership.club.members_group_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if membership.role.is_board:
|
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
||||||
club_groups.append(
|
club_groups.append(
|
||||||
User.groups.through(
|
User.groups.through(
|
||||||
user_id=membership.user_id,
|
user_id=membership.user_id,
|
||||||
|
|||||||
+3
-11
@@ -3,7 +3,7 @@ from typing import Annotated
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from ninja import FilterLookup, FilterSchema, ModelSchema
|
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.schemas import NonEmptyStr, SimpleUserSchema
|
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||||
|
|
||||||
|
|
||||||
@@ -39,21 +39,14 @@ class ClubProfileSchema(ModelSchema):
|
|||||||
return obj.get_absolute_url()
|
return obj.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
class ClubRoleSchema(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = ClubRole
|
|
||||||
fields = ["id", "name", "is_presidency", "is_board"]
|
|
||||||
|
|
||||||
|
|
||||||
class ClubMemberSchema(ModelSchema):
|
class ClubMemberSchema(ModelSchema):
|
||||||
"""A schema to represent all memberships in a club."""
|
"""A schema to represent all memberships in a club."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Membership
|
model = Membership
|
||||||
fields = ["start_date", "end_date", "description"]
|
fields = ["start_date", "end_date", "role", "description"]
|
||||||
|
|
||||||
user: SimpleUserSchema
|
user: SimpleUserSchema
|
||||||
role: ClubRoleSchema
|
|
||||||
|
|
||||||
|
|
||||||
class ClubSchema(ModelSchema):
|
class ClubSchema(ModelSchema):
|
||||||
@@ -69,7 +62,6 @@ class UserMembershipSchema(ModelSchema):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Membership
|
model = Membership
|
||||||
fields = ["id", "start_date", "description"]
|
fields = ["id", "start_date", "role", "description"]
|
||||||
|
|
||||||
club: SimpleClubSchema
|
club: SimpleClubSchema
|
||||||
role: ClubRoleSchema
|
|
||||||
|
|||||||
@@ -26,9 +26,10 @@
|
|||||||
{% if club.logo %}
|
{% if club.logo %}
|
||||||
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
|
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h3>{{ club.name }}</h3>
|
|
||||||
{% if page_revision %}
|
{% if page_revision %}
|
||||||
{{ page_revision|markdown }}
|
{{ page_revision|markdown }}
|
||||||
|
{% else %}
|
||||||
|
<h3>{{ club.name }}</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
{% for m in members %}
|
{% for m in members %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user_profile_link(m.user) }}</td>
|
<td>{{ user_profile_link(m.user) }}</td>
|
||||||
<td>{{ m.role.name }}</td>
|
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
{%- if can_end_membership -%}
|
{%- if can_end_membership -%}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{% for member in old_members %}
|
{% for member in old_members %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user_profile_link(member.user) }}</td>
|
<td>{{ user_profile_link(member.user) }}</td>
|
||||||
<td>{{ member.role.name }}</td>
|
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
|
||||||
<td>{{ member.description }}</td>
|
<td>{{ member.description }}</td>
|
||||||
<td>{{ member.start_date }}</td>
|
<td>{{ member.start_date }}</td>
|
||||||
<td>{{ member.end_date }}</td>
|
<td>{{ member.end_date }}</td>
|
||||||
|
|||||||
+5
-15
@@ -8,7 +8,7 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
@@ -43,11 +43,6 @@ class TestClub(TestCase):
|
|||||||
|
|
||||||
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
cls.president_role = baker.make(
|
|
||||||
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
|
|
||||||
)
|
|
||||||
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
|
|
||||||
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
|
|
||||||
cls.new_members_url = reverse(
|
cls.new_members_url = reverse(
|
||||||
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
||||||
)
|
)
|
||||||
@@ -56,17 +51,12 @@ class TestClub(TestCase):
|
|||||||
yesterday = now() - timedelta(days=1)
|
yesterday = now() - timedelta(days=1)
|
||||||
membership_recipe = Recipe(Membership, club=cls.club)
|
membership_recipe = Recipe(Membership, club=cls.club)
|
||||||
membership_recipe.make(
|
membership_recipe.make(
|
||||||
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
|
user=cls.simple_board_member, start_date=a_month_ago, role=3
|
||||||
)
|
|
||||||
membership_recipe.make(user=cls.richard, role=cls.member_role)
|
|
||||||
membership_recipe.make(
|
|
||||||
user=cls.president, start_date=a_month_ago, role=cls.president_role
|
|
||||||
)
|
)
|
||||||
|
membership_recipe.make(user=cls.richard, role=1)
|
||||||
|
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
|
||||||
membership_recipe.make( # sli was a member but isn't anymore
|
membership_recipe.make( # sli was a member but isn't anymore
|
||||||
user=cls.sli,
|
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
|
||||||
start_date=a_month_ago,
|
|
||||||
end_date=yesterday,
|
|
||||||
role=cls.board_role,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
+5
-91
@@ -1,16 +1,13 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
|
||||||
from django.db import ProgrammingError
|
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertRedirects
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
@@ -22,19 +19,11 @@ def test_club_queryset_having_board_member():
|
|||||||
membership_recipe = Recipe(
|
membership_recipe = Recipe(
|
||||||
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
||||||
)
|
)
|
||||||
|
membership_recipe.make(club=clubs[0], role=1)
|
||||||
|
membership_recipe.make(club=clubs[1], role=3)
|
||||||
|
membership_recipe.make(club=clubs[2], role=7)
|
||||||
membership_recipe.make(
|
membership_recipe.make(
|
||||||
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
|
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
|
||||||
)
|
|
||||||
membership_recipe.make(
|
|
||||||
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
|
|
||||||
)
|
|
||||||
membership_recipe.make(
|
|
||||||
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
|
|
||||||
)
|
|
||||||
membership_recipe.make(
|
|
||||||
club=clubs[3],
|
|
||||||
role=baker.make(ClubRole, club=clubs[3], is_board=True),
|
|
||||||
end_date=localdate() - timedelta(days=1),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
||||||
@@ -50,78 +39,3 @@ def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
|
|||||||
headers = {"HX-Request": True} if is_fragment else {}
|
headers = {"HX-Request": True} if is_fragment else {}
|
||||||
res = client.get(reverse("club:club_list"), headers=headers)
|
res = client.get(reverse("club:club_list"), headers=headers)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def assert_club_created(club_name: str):
|
|
||||||
club = Club.objects.last()
|
|
||||||
assert club.name == club_name
|
|
||||||
assert club.board_group.name == f"{club_name} - Bureau"
|
|
||||||
assert club.members_group.name == f"{club_name} - Membres"
|
|
||||||
# default roles should be added on club creation,
|
|
||||||
# whether the creation happens on the admin site or on the user site
|
|
||||||
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
|
|
||||||
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
|
|
||||||
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
|
|
||||||
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
|
|
||||||
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_create_view(admin_client: Client):
|
|
||||||
"""Test that the club creation view works well"""
|
|
||||||
res = admin_client.get(reverse("club:club_new"))
|
|
||||||
assert res.status_code == 200
|
|
||||||
res = admin_client.post(
|
|
||||||
reverse("club:club_new"),
|
|
||||||
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
|
|
||||||
)
|
|
||||||
club = Club.objects.last()
|
|
||||||
assertRedirects(res, club.get_absolute_url())
|
|
||||||
assert_club_created("foo")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_default_roles_for_club_with_roles_fails():
|
|
||||||
"""Test that an Error is raised if trying to create
|
|
||||||
default roles for a club that already has roles.
|
|
||||||
"""
|
|
||||||
club = baker.make(Club)
|
|
||||||
baker.make(ClubRole, club=club)
|
|
||||||
with pytest.raises(ProgrammingError):
|
|
||||||
club.create_default_roles()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestAdminInterface:
|
|
||||||
def test_create(self, admin_client: Client):
|
|
||||||
"""Test the creation of a club via the admin interface."""
|
|
||||||
res = admin_client.post(
|
|
||||||
reverse("admin:club_club_add"),
|
|
||||||
data={
|
|
||||||
"name": "foo",
|
|
||||||
"parent": settings.SITH_MAIN_CLUB_ID,
|
|
||||||
"address": "Rome",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assertRedirects(res, reverse("admin:club_club_changelist"))
|
|
||||||
assert_club_created("foo")
|
|
||||||
|
|
||||||
def test_change(self, admin_client: Client):
|
|
||||||
"""Test the edition of a club via the admin interface."""
|
|
||||||
club = baker.make(Club)
|
|
||||||
res = admin_client.post(
|
|
||||||
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
|
|
||||||
data={
|
|
||||||
"name": "foo",
|
|
||||||
"page": club.page_id,
|
|
||||||
"home": club.home_id,
|
|
||||||
"address": club.address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assertRedirects(res, reverse("admin:club_club_changelist"))
|
|
||||||
club.refresh_from_db()
|
|
||||||
assert club.name == "foo"
|
|
||||||
# Club roles shouldn't be modified when editing the club on the admin interface
|
|
||||||
# This club had no roles beforehand, therefore it shouldn't have roles now.
|
|
||||||
assert not club.roles.exists()
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -9,7 +8,7 @@ from model_bakery import baker
|
|||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertNumQueries
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, Page, User
|
from core.models import Group, Page, User
|
||||||
|
|
||||||
@@ -27,10 +26,8 @@ class TestClubSearch(TestCase):
|
|||||||
"id", flat=True
|
"id", flat=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Membership.objects.all().delete()
|
Page.objects.exclude(club=None).delete()
|
||||||
ClubRole.objects.all().delete()
|
|
||||||
Club.objects.all().delete()
|
Club.objects.all().delete()
|
||||||
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
|
|
||||||
Group.objects.filter(id__in=groups).delete()
|
Group.objects.filter(id__in=groups).delete()
|
||||||
|
|
||||||
cls.clubs = baker.make(
|
cls.clubs = baker.make(
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from model_bakery import baker, seq
|
|
||||||
from model_bakery.recipe import Recipe
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_order_auto():
|
|
||||||
"""Test that newly created roles are put in the right place."""
|
|
||||||
club = baker.make(Club)
|
|
||||||
recipe = Recipe(ClubRole, club=club, name=seq("role "))
|
|
||||||
# bulk create initial roles (1 presidency, 1 board, 1 member)
|
|
||||||
roles = recipe.make(
|
|
||||||
is_board=iter([True, True, False]),
|
|
||||||
is_presidency=iter([True, False, False]),
|
|
||||||
order=iter([1, 2, 3]),
|
|
||||||
_quantity=3,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
# then create the remaining roles one by one (like they will be in prod)
|
|
||||||
# each new role should be placed at the end of its category
|
|
||||||
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
|
|
||||||
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
|
|
||||||
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
|
|
||||||
assert list(club.roles.order_by("order")) == [
|
|
||||||
roles[0],
|
|
||||||
role_a,
|
|
||||||
roles[1],
|
|
||||||
role_b,
|
|
||||||
roles[2],
|
|
||||||
role_c,
|
|
||||||
]
|
|
||||||
+3
-13
@@ -4,7 +4,7 @@ from django.urls import reverse
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
|
|
||||||
|
|
||||||
@@ -12,12 +12,7 @@ from core.baker_recipes import subscriber_user
|
|||||||
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
||||||
baker.make(
|
baker.make(Membership, club=club, user=user, role=7)
|
||||||
Membership,
|
|
||||||
club=club,
|
|
||||||
user=user,
|
|
||||||
role=baker.make(ClubRole, club=club, is_board=True),
|
|
||||||
)
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.post(
|
res = client.post(
|
||||||
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
||||||
@@ -37,12 +32,7 @@ def test_edit_club_page_doesnt_crash(client: Client):
|
|||||||
"""crash test for club:club_edit"""
|
"""crash test for club:club_edit"""
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(
|
baker.make(Membership, club=club, user=user, role=3)
|
||||||
Membership,
|
|
||||||
club=club,
|
|
||||||
user=user,
|
|
||||||
role=baker.make(ClubRole, club=club, is_board=True),
|
|
||||||
)
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from model_bakery import baker
|
|
||||||
|
|
||||||
from club.forms import MailingForm
|
from club.forms import MailingForm
|
||||||
from club.models import Club, ClubRole, Mailing, Membership
|
from club.models import Club, Mailing, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ class TestMailingForm(TestCase):
|
|||||||
user=cls.rbatsbak,
|
user=cls.rbatsbak,
|
||||||
club=cls.club,
|
club=cls.club,
|
||||||
start_date=timezone.now(),
|
start_date=timezone.now(),
|
||||||
role=baker.make(ClubRole, club=cls.club, is_board=True),
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
def test_mailing_list_add_no_moderation(self):
|
def test_mailing_list_add_no_moderation(self):
|
||||||
|
|||||||
+90
-159
@@ -1,20 +1,20 @@
|
|||||||
import itertools
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localdate, localtime, now
|
from django.utils.timezone import localdate, localtime, now
|
||||||
from model_bakery import baker, seq
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.forms import ClubAddMemberForm, JoinClubForm
|
from club.forms import ClubAddMemberForm, JoinClubForm
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from club.tests.base import TestClub
|
from club.tests.base import TestClub
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import AnonymousUser, User
|
from core.models import AnonymousUser, User
|
||||||
@@ -75,22 +75,17 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
def test_update_change_club_groups(self):
|
def test_update_change_club_groups(self):
|
||||||
"""Test that `update` set the user groups accordingly."""
|
"""Test that `update` set the user groups accordingly."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
board_role, member_role = baker.make(
|
membership = baker.make(Membership, end_date=None, user=user, role=5)
|
||||||
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
|
|
||||||
)
|
|
||||||
membership = baker.make(
|
|
||||||
Membership, end_date=None, user=user, role=board_role, club=board_role.club
|
|
||||||
)
|
|
||||||
members_group = membership.club.members_group
|
members_group = membership.club.members_group
|
||||||
board_group = membership.club.board_group
|
board_group = membership.club.board_group
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert user.groups.contains(board_group)
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
user.memberships.update(role=member_role) # from board to simple member
|
user.memberships.update(role=1) # from board to simple member
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert not user.groups.contains(board_group)
|
assert not user.groups.contains(board_group)
|
||||||
|
|
||||||
user.memberships.update(role=board_role) # from member to board
|
user.memberships.update(role=5) # from member to board
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert user.groups.contains(board_group)
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
@@ -101,17 +96,7 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
def test_delete_remove_from_groups(self):
|
def test_delete_remove_from_groups(self):
|
||||||
"""Test that `delete` removes from club groups"""
|
"""Test that `delete` removes from club groups"""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
club = baker.make(Club)
|
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
|
||||||
roles = baker.make(
|
|
||||||
ClubRole,
|
|
||||||
is_board=iter([False, True]),
|
|
||||||
club=club,
|
|
||||||
_quantity=2,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
memberships = baker.make(
|
|
||||||
Membership, club=club, role=iter(roles), user=user, _quantity=2
|
|
||||||
)
|
|
||||||
club_groups = {
|
club_groups = {
|
||||||
memberships[0].club.members_group,
|
memberships[0].club.members_group,
|
||||||
memberships[1].club.members_group,
|
memberships[1].club.members_group,
|
||||||
@@ -127,20 +112,13 @@ class TestMembershipEditableBy(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
Membership.objects.all().delete()
|
Membership.objects.all().delete()
|
||||||
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
||||||
roles = baker.make(
|
|
||||||
ClubRole,
|
|
||||||
is_presidency=itertools.cycle([True, False, False, False]),
|
|
||||||
is_board=itertools.cycle([True, True, True, False]),
|
|
||||||
order=itertools.cycle(range(4)),
|
|
||||||
club=iter(
|
|
||||||
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
|
|
||||||
),
|
|
||||||
_quantity=8,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
cls.memberships = [
|
cls.memberships = [
|
||||||
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
|
*baker.make(
|
||||||
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
|
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
|
||||||
|
),
|
||||||
|
*baker.make(
|
||||||
|
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_admin_user(self):
|
def test_admin_user(self):
|
||||||
@@ -162,7 +140,7 @@ class TestMembershipEditableBy(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMembership(TestClub):
|
class TestMembership(TestClub):
|
||||||
def assert_membership_started_today(self, user: User, role: ClubRole):
|
def assert_membership_started_today(self, user: User, role: int):
|
||||||
"""Assert that the given membership is active and started today."""
|
"""Assert that the given membership is active and started today."""
|
||||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||||
assert membership is not None
|
assert membership is not None
|
||||||
@@ -211,27 +189,21 @@ class TestMembership(TestClub):
|
|||||||
"Marquer comme ancien",
|
"Marquer comme ancien",
|
||||||
]
|
]
|
||||||
rows = table.find("tbody").find_all("tr")
|
rows = table.find("tbody").find_all("tr")
|
||||||
memberships = (
|
memberships = self.club.members.ongoing().order_by("-role")
|
||||||
self.club.members.ongoing()
|
for row, membership in zip(
|
||||||
.order_by("role__order")
|
rows, memberships.select_related("user"), strict=False
|
||||||
.select_related("user", "role")
|
):
|
||||||
)
|
|
||||||
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
|
|
||||||
for row, membership in zip(rows, memberships, strict=False):
|
|
||||||
user = membership.user
|
user = membership.user
|
||||||
user_url = reverse("core:user_profile", args=[user.id])
|
user_url = reverse("core:user_profile", args=[user.id])
|
||||||
cols = row.find_all("td")
|
cols = row.find_all("td")
|
||||||
user_link = cols[0].find("a")
|
user_link = cols[0].find("a")
|
||||||
assert user_link.attrs["href"] == user_url
|
assert user_link.attrs["href"] == user_url
|
||||||
assert user_link.text == user.get_display_name()
|
assert user_link.text == user.get_display_name()
|
||||||
assert cols[1].text == membership.role.name
|
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
|
||||||
assert cols[2].text == membership.description
|
assert cols[2].text == membership.description
|
||||||
assert cols[3].text == str(membership.start_date)
|
assert cols[3].text == str(membership.start_date)
|
||||||
|
|
||||||
if (
|
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
|
||||||
membership.role.order > user_role.order
|
|
||||||
or membership.user_id == self.simple_board_member.id
|
|
||||||
):
|
|
||||||
# 3 is the role of simple_board_member
|
# 3 is the role of simple_board_member
|
||||||
form_input = cols[4].find("input")
|
form_input = cols[4].find("input")
|
||||||
expected_attrs = {
|
expected_attrs = {
|
||||||
@@ -247,15 +219,14 @@ class TestMembership(TestClub):
|
|||||||
"""Test that root users can add members to clubs"""
|
"""Test that root users can add members to clubs"""
|
||||||
self.client.force_login(self.root)
|
self.client.force_login(self.root)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.new_members_url,
|
self.new_members_url, {"user": self.subscriber.id, "role": 3}
|
||||||
{"user": self.subscriber.id, "role": self.board_role.id},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||||
"club:club_members", kwargs={"club_id": self.club.id}
|
"club:club_members", kwargs={"club_id": self.club.id}
|
||||||
)
|
)
|
||||||
self.subscriber.refresh_from_db()
|
self.subscriber.refresh_from_db()
|
||||||
self.assert_membership_started_today(self.subscriber, role=self.board_role)
|
self.assert_membership_started_today(self.subscriber, role=3)
|
||||||
|
|
||||||
def test_add_unauthorized_members(self):
|
def test_add_unauthorized_members(self):
|
||||||
"""Test that users who are not currently subscribed
|
"""Test that users who are not currently subscribed
|
||||||
@@ -263,7 +234,7 @@ class TestMembership(TestClub):
|
|||||||
"""
|
"""
|
||||||
for user in self.public, self.old_subscriber:
|
for user in self.public, self.old_subscriber:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": user.id, "role": self.member_role},
|
data={"user": user.id, "role": 1},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -284,7 +255,7 @@ class TestMembership(TestClub):
|
|||||||
nb_memberships = self.simple_board_member.memberships.count()
|
nb_memberships = self.simple_board_member.memberships.count()
|
||||||
self.client.post(
|
self.client.post(
|
||||||
self.members_url,
|
self.members_url,
|
||||||
{"users": self.simple_board_member.id, "role": self.member_role},
|
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
|
||||||
)
|
)
|
||||||
self.simple_board_member.refresh_from_db()
|
self.simple_board_member.refresh_from_db()
|
||||||
assert nb_memberships == self.simple_board_member.memberships.count()
|
assert nb_memberships == self.simple_board_member.memberships.count()
|
||||||
@@ -303,7 +274,7 @@ class TestMembership(TestClub):
|
|||||||
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
||||||
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": members, "role": self.member_role},
|
data={"user": members, "role": 1},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -317,6 +288,44 @@ class TestMembership(TestClub):
|
|||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
assert self.club.members.count() == nb_memberships
|
assert self.club.members.count() == nb_memberships
|
||||||
|
|
||||||
|
def test_president_add_members(self):
|
||||||
|
"""Test that the president of the club can add members."""
|
||||||
|
president = self.club.members.get(role=10).user
|
||||||
|
nb_club_membership = self.club.members.count()
|
||||||
|
nb_subscriber_memberships = self.subscriber.memberships.count()
|
||||||
|
self.client.force_login(president)
|
||||||
|
response = self.client.post(
|
||||||
|
self.new_members_url, {"user": self.subscriber.id, "role": 9}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||||
|
"club:club_members", kwargs={"club_id": self.club.id}
|
||||||
|
)
|
||||||
|
self.club.refresh_from_db()
|
||||||
|
self.subscriber.refresh_from_db()
|
||||||
|
assert self.club.members.count() == nb_club_membership + 1
|
||||||
|
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
||||||
|
self.assert_membership_started_today(self.subscriber, role=9)
|
||||||
|
|
||||||
|
def test_add_member_greater_role(self):
|
||||||
|
"""Test that a member of the club member cannot create
|
||||||
|
a membership with a greater role than its own.
|
||||||
|
"""
|
||||||
|
form = ClubAddMemberForm(
|
||||||
|
data={"user": self.subscriber.id, "role": 10},
|
||||||
|
request_user=self.simple_board_member,
|
||||||
|
club=self.club,
|
||||||
|
)
|
||||||
|
nb_memberships = self.club.members.count()
|
||||||
|
|
||||||
|
assert not form.is_valid()
|
||||||
|
assert form.errors == {
|
||||||
|
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
|
||||||
|
}
|
||||||
|
self.club.refresh_from_db()
|
||||||
|
assert nb_memberships == self.club.members.count()
|
||||||
|
assert not self.subscriber.memberships.filter(club=self.club).exists()
|
||||||
|
|
||||||
def test_add_member_without_role(self):
|
def test_add_member_without_role(self):
|
||||||
"""Test that trying to add members without specifying their role fails."""
|
"""Test that trying to add members without specifying their role fails."""
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
@@ -327,9 +336,8 @@ class TestMembership(TestClub):
|
|||||||
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
||||||
|
|
||||||
def test_add_member_already_there(self):
|
def test_add_member_already_there(self):
|
||||||
role = ClubRole.objects.get(members__user=self.simple_board_member)
|
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": self.simple_board_member, "role": role.id},
|
data={"user": self.simple_board_member, "role": 3},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -340,27 +348,22 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
def test_add_other_member_forbidden(self):
|
def test_add_other_member_forbidden(self):
|
||||||
non_member = subscriber_user.make()
|
non_member = subscriber_user.make()
|
||||||
simple_member = baker.make(
|
simple_member = baker.make(Membership, club=self.club, role=1).user
|
||||||
Membership, club=self.club, role=self.member_role
|
|
||||||
).user
|
|
||||||
for user in non_member, simple_member:
|
for user in non_member, simple_member:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": subscriber_user.make(), "role": self.member_role.id},
|
data={"user": subscriber_user.make(), "role": 1},
|
||||||
request_user=user,
|
request_user=user,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {
|
assert form.errors == {
|
||||||
"role": [
|
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
|
||||||
"Sélectionnez un choix valide. "
|
|
||||||
"Ce choix ne fait pas partie de ceux disponibles."
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_simple_members_dont_see_form_anymore(self):
|
def test_simple_members_dont_see_form_anymore(self):
|
||||||
"""Test that simple club members don't see the form to add members"""
|
"""Test that simple club members don't see the form to add members"""
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, club=self.club, user=user, role=self.member_role)
|
baker.make(Membership, club=self.club, user=user, role=1)
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
res = self.client.get(self.members_url)
|
res = self.client.get(self.members_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -379,10 +382,9 @@ class TestMembership(TestClub):
|
|||||||
"""Test that board members of the club can end memberships
|
"""Test that board members of the club can end memberships
|
||||||
of users with lower roles.
|
of users with lower roles.
|
||||||
"""
|
"""
|
||||||
|
# reminder : simple_board_member has role 3
|
||||||
self.client.force_login(self.simple_board_member)
|
self.client.force_login(self.simple_board_member)
|
||||||
role = baker.make(ClubRole, club=self.club, is_board=True)
|
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
|
||||||
role.below(self.board_role)
|
|
||||||
membership = baker.make(Membership, club=self.club, role=role)
|
|
||||||
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||||
self.assertRedirects(response, self.members_url)
|
self.assertRedirects(response, self.members_url)
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -392,9 +394,7 @@ class TestMembership(TestClub):
|
|||||||
"""Test that board members of the club cannot end memberships
|
"""Test that board members of the club cannot end memberships
|
||||||
of users with higher roles.
|
of users with higher roles.
|
||||||
"""
|
"""
|
||||||
membership = self.president.memberships.filter(
|
membership = self.president.memberships.filter(club=self.club).first()
|
||||||
club=self.club, end_date=None
|
|
||||||
).first()
|
|
||||||
self.client.force_login(self.simple_board_member)
|
self.client.force_login(self.simple_board_member)
|
||||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -436,9 +436,7 @@ class TestMembership(TestClub):
|
|||||||
def test_remove_from_club_group(self):
|
def test_remove_from_club_group(self):
|
||||||
"""Test that when a membership ends, the user is removed from club groups."""
|
"""Test that when a membership ends, the user is removed from club groups."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
baker.make(
|
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
||||||
Membership, user=user, club=self.club, end_date=None, role=self.board_role
|
|
||||||
)
|
|
||||||
assert user.groups.contains(self.club.members_group)
|
assert user.groups.contains(self.club.members_group)
|
||||||
assert user.groups.contains(self.club.board_group)
|
assert user.groups.contains(self.club.board_group)
|
||||||
user.memberships.update(end_date=localdate())
|
user.memberships.update(end_date=localdate())
|
||||||
@@ -449,20 +447,18 @@ class TestMembership(TestClub):
|
|||||||
"""Test that when a membership begins, the user is added to the club group."""
|
"""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.members_group)
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
baker.make(
|
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
|
||||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
|
||||||
)
|
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
|
|
||||||
def test_change_position_in_club(self):
|
def test_change_position_in_club(self):
|
||||||
"""Test that when moving from board to members, club group change"""
|
"""Test that when moving from board to members, club group change"""
|
||||||
membership = baker.make(
|
membership = baker.make(
|
||||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
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.members_group)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
membership.role = self.member_role
|
membership.role = 1
|
||||||
membership.save()
|
membership.save()
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
@@ -475,11 +471,7 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
# make sli a board member
|
# make sli a board member
|
||||||
self.sli.memberships.all().delete()
|
self.sli.memberships.all().delete()
|
||||||
Membership(
|
Membership(club=self.ae, user=self.sli, role=3).save()
|
||||||
club=self.ae,
|
|
||||||
user=self.sli,
|
|
||||||
role=baker.make(ClubRole, club=self.ae, is_board=True),
|
|
||||||
).save()
|
|
||||||
assert self.club.is_owned_by(self.sli)
|
assert self.club.is_owned_by(self.sli)
|
||||||
|
|
||||||
def test_change_club_name(self):
|
def test_change_club_name(self):
|
||||||
@@ -505,7 +497,7 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_membership_set_old(client: Client):
|
def test_membership_set_old(client: Client):
|
||||||
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
|
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
|
||||||
client.force_login(membership.user)
|
client.force_login(membership.user)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
||||||
@@ -532,50 +524,6 @@ def test_membership_delete(client: Client):
|
|||||||
assert not Membership.objects.filter(id=membership.id).exists()
|
assert not Membership.objects.filter(id=membership.id).exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestAddMemberForm(TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
cls.club = baker.make(Club)
|
|
||||||
cls.roles = baker.make(
|
|
||||||
ClubRole,
|
|
||||||
club=cls.club,
|
|
||||||
is_board=iter([True, True, True, True, False, False]),
|
|
||||||
is_presidency=iter([True, True, False, False, False, False]),
|
|
||||||
order=seq(0),
|
|
||||||
_quantity=6,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
cls.roles[-1].is_active = False
|
|
||||||
cls.roles[-1].save()
|
|
||||||
|
|
||||||
def test_admin(self):
|
|
||||||
"""Test that admin users can give any active role."""
|
|
||||||
user = baker.make(
|
|
||||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
|
||||||
)
|
|
||||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
|
||||||
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
|
||||||
|
|
||||||
def test_president(self):
|
|
||||||
"""Test that someone with a presidency role can give any active role."""
|
|
||||||
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
|
|
||||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
|
||||||
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
|
||||||
|
|
||||||
def test_board_member(self):
|
|
||||||
"""Test that someone with a board role can give lower active role."""
|
|
||||||
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
|
|
||||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
|
||||||
assert list(form.fields["role"].queryset) == self.roles[3:-1]
|
|
||||||
|
|
||||||
def test_simple_member(self):
|
|
||||||
"""Test that someone with a non-board role cannot give roles."""
|
|
||||||
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
|
|
||||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
|
||||||
assert list(form.fields["role"].queryset) == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestJoinClub:
|
class TestJoinClub:
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -583,64 +531,55 @@ class TestJoinClub:
|
|||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("user_factory", "board_role", "errors"),
|
("user_factory", "role", "errors"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
subscriber_user.make,
|
subscriber_user.make,
|
||||||
True,
|
2,
|
||||||
{
|
{
|
||||||
"role": [
|
"role": [
|
||||||
"Sélectionnez un choix valide. "
|
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
|
||||||
"Ce choix ne fait pas partie de ceux disponibles."
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
lambda: baker.make(User),
|
lambda: baker.make(User),
|
||||||
False,
|
1,
|
||||||
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_join_club_errors(
|
def test_join_club_errors(
|
||||||
self, user_factory: Callable[[], User], board_role, errors: dict
|
self, user_factory: Callable[[], User], role: int, errors: dict
|
||||||
):
|
):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = user_factory()
|
user = user_factory()
|
||||||
role = baker.make(ClubRole, club=club, is_board=board_role)
|
form = JoinClubForm(club=club, request_user=user, data={"role": role})
|
||||||
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
|
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == errors
|
assert form.errors == errors
|
||||||
|
|
||||||
def test_user_already_in_club(self):
|
def test_user_already_in_club(self):
|
||||||
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
role = baker.make(ClubRole, is_board=False)
|
baker.make(Membership, user=user, club=club)
|
||||||
baker.make(Membership, user=user, club=role.club)
|
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
||||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
||||||
|
|
||||||
def test_ok(self):
|
def test_ok(self):
|
||||||
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
role = baker.make(ClubRole, is_board=False)
|
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
||||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
form.save()
|
form.save()
|
||||||
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
|
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
|
||||||
|
|
||||||
|
|
||||||
class TestOldMembersView(TestCase):
|
class TestOldMembersView(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
roles = baker.make(
|
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
|
||||||
ClubRole,
|
|
||||||
club=club,
|
|
||||||
is_board=itertools.cycle([True, True, False]),
|
|
||||||
order=seq(0),
|
|
||||||
_quantity=10,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
cls.memberships = baker.make(
|
cls.memberships = baker.make(
|
||||||
Membership,
|
Membership,
|
||||||
role=iter(roles),
|
role=iter(roles),
|
||||||
@@ -665,11 +604,3 @@ class TestOldMembersView(TestCase):
|
|||||||
self.client.force_login(baker.make(User))
|
self.client.force_login(baker.make(User))
|
||||||
res = self.client.get(self.url)
|
res = self.client.get(self.url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
|
|
||||||
def test_context_data(self):
|
|
||||||
# mark a membership as not ended, to make sure it is excluded from the result
|
|
||||||
self.memberships[0].end_date = None
|
|
||||||
self.memberships[0].save()
|
|
||||||
self.client.force_login(subscriber_user.make())
|
|
||||||
res = self.client.get(self.url)
|
|
||||||
assert list(res.context_data.get("old_members")) == self.memberships[1:]
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import PageRev, User
|
from core.models import PageRev, User
|
||||||
@@ -59,12 +59,7 @@ def test_page_revision(client: Client):
|
|||||||
def test_edit_page(client: Client):
|
def test_edit_page(client: Client):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(
|
baker.make(Membership, user=user, club=club, role=3)
|
||||||
Membership,
|
|
||||||
user=user,
|
|
||||||
club=club,
|
|
||||||
role=baker.make(ClubRole, club=club, is_board=True),
|
|
||||||
)
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
||||||
content = "# foo\nLorem ipsum dolor sit amet"
|
content = "# foo\nLorem ipsum dolor sit amet"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.utils.timezone import localdate
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from club.schemas import UserMembershipSchema
|
from club.schemas import UserMembershipSchema
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Page
|
from core.models import Page
|
||||||
@@ -19,10 +19,7 @@ class TestFetchClub(TestCase):
|
|||||||
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
||||||
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
||||||
recipe = Recipe(
|
recipe = Recipe(
|
||||||
Membership,
|
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
|
||||||
user=cls.user,
|
|
||||||
start_date=localdate() - timedelta(days=2),
|
|
||||||
role=baker.make(ClubRole),
|
|
||||||
)
|
)
|
||||||
cls.members = Membership.objects.bulk_create(
|
cls.members = Membership.objects.bulk_create(
|
||||||
[
|
[
|
||||||
|
|||||||
+9
-11
@@ -28,6 +28,7 @@ import csv
|
|||||||
import itertools
|
import itertools
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
||||||
@@ -354,7 +355,7 @@ class ClubMembersView(
|
|||||||
membership = self.object.get_membership_for(self.request.user)
|
membership = self.object.get_membership_for(self.request.user)
|
||||||
if (
|
if (
|
||||||
membership
|
membership
|
||||||
and not membership.role.is_board
|
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
and not self.request.user.has_perm("club.add_membership")
|
and not self.request.user.has_perm("club.add_membership")
|
||||||
):
|
):
|
||||||
# Simple club members won't see the form anymore.
|
# Simple club members won't see the form anymore.
|
||||||
@@ -379,8 +380,8 @@ class ClubMembersView(
|
|||||||
kwargs["members"] = list(
|
kwargs["members"] = list(
|
||||||
self.object.members.ongoing()
|
self.object.members.ongoing()
|
||||||
.annotate(is_editable=Q(id__in=editable))
|
.annotate(is_editable=Q(id__in=editable))
|
||||||
.order_by("role__order")
|
.order_by("-role")
|
||||||
.select_related("user", "role")
|
.select_related("user")
|
||||||
)
|
)
|
||||||
kwargs["can_end_membership"] = len(editable) > 0
|
kwargs["can_end_membership"] = len(editable) > 0
|
||||||
return kwargs
|
return kwargs
|
||||||
@@ -408,8 +409,8 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
|||||||
return super().get_context_data(**kwargs) | {
|
return super().get_context_data(**kwargs) | {
|
||||||
"old_members": (
|
"old_members": (
|
||||||
self.object.members.exclude(end_date=None)
|
self.object.members.exclude(end_date=None)
|
||||||
.order_by("role__order", "description", "-end_date")
|
.order_by("-role", "description", "-end_date")
|
||||||
.select_related("user", "role")
|
.select_related("user")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,11 +581,6 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
|
|||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
permission_required = "club.add_club"
|
permission_required = "club.add_club"
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
res = super().form_valid(form)
|
|
||||||
self.object.create_default_roles()
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
|
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
|
||||||
"""Set a membership as being old."""
|
"""Set a membership as being old."""
|
||||||
@@ -765,7 +761,9 @@ class MailingAutoGenerationView(View):
|
|||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
club = self.mailing.club
|
club = self.mailing.club
|
||||||
self.mailing.subscriptions.all().delete()
|
self.mailing.subscriptions.all().delete()
|
||||||
members = club.members.ongoing().filter(role__is_board=True)
|
members = club.members.filter(
|
||||||
|
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
|
||||||
|
).exclude(end_date__lte=timezone.now())
|
||||||
for member in members.all():
|
for member in members.all():
|
||||||
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
||||||
return redirect("club:mailing", club_id=club.id)
|
return redirect("club:mailing", club_id=club.id)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
#news {
|
#news {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1em;
|
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -27,14 +26,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
|
background: $second-color;
|
||||||
background: lighten($second-color, 5%);
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
box-shadow: var(--box-shadow);
|
padding: 0.4em;
|
||||||
padding: .75rem;
|
|
||||||
margin: 0 0 0.5em 0;
|
margin: 0 0 0.5em 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
&:not(:first-of-type) {
|
&:not(:first-of-type) {
|
||||||
margin: 2em 0 1em 0;
|
margin: 2em 0 1em 0;
|
||||||
@@ -42,11 +39,12 @@
|
|||||||
|
|
||||||
.feed {
|
.feed {
|
||||||
float: right;
|
float: right;
|
||||||
color: #e25512;
|
color: #f26522;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $small-devices) {
|
@media screen and (max-width: $small-devices) {
|
||||||
|
|
||||||
#left_column,
|
#left_column,
|
||||||
#right_column {
|
#right_column {
|
||||||
flex: 100%;
|
flex: 100%;
|
||||||
@@ -59,7 +57,6 @@
|
|||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
margin-top: 1em;
|
|
||||||
|
|
||||||
#load-more-news-button {
|
#load-more-news-button {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -79,11 +76,15 @@
|
|||||||
font-size: 70%;
|
font-size: 70%;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#links_content {
|
#links_content {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
min-height: 20em;
|
min-height: 20em;
|
||||||
padding: 1em;
|
padding-bottom: 1em;
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
@@ -120,8 +121,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#birthdays_content {
|
#birthdays_content {
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
|
||||||
padding: 1em;
|
|
||||||
ul.birthdays_year {
|
ul.birthdays_year {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
@@ -136,7 +135,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin: .5em 0 0 1em;
|
margin: 0;
|
||||||
|
margin-left: 1em;
|
||||||
list-style-type: square;
|
list-style-type: square;
|
||||||
list-style-position: inside;
|
list-style-position: inside;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@@ -150,13 +150,9 @@
|
|||||||
/* EVENTS TODAY AND NEXT FEW DAYS */
|
/* EVENTS TODAY AND NEXT FEW DAYS */
|
||||||
.news_events_group {
|
.news_events_group {
|
||||||
box-shadow: $shadow-color 1px 1px 1px;
|
box-shadow: $shadow-color 1px 1px 1px;
|
||||||
margin-left: 0;
|
margin-left: 1em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
|
||||||
@media screen and (max-width: $small-devices) {
|
|
||||||
margin-left: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news_events_group_date {
|
.news_events_group_date {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
padding: 0.6em;
|
padding: 0.6em;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
|
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
|
||||||
</h3>
|
</h3>
|
||||||
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
|
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
|
||||||
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
|
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
|
||||||
<i class="fa fa-plus"></i>
|
<i class="fa fa-plus"></i>
|
||||||
{% trans %}Create news{% endtrans %}
|
{% trans %}Create news{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from model_bakery import baker
|
|||||||
|
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, Notification, SithFile, User
|
from core.models import Group, Notification, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -18,7 +18,6 @@ def test_notification_created():
|
|||||||
past_news = baker.make(News, is_published=False)
|
past_news = baker.make(News, is_published=False)
|
||||||
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
|
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
|
||||||
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete()
|
|
||||||
com_admin_group.users.all().delete()
|
com_admin_group.users.all().delete()
|
||||||
Notification.objects.all().delete()
|
Notification.objects.all().delete()
|
||||||
com_admin = baker.make(User, groups=[com_admin_group])
|
com_admin = baker.make(User, groups=[com_admin_group])
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import AnonymousUser, Group, User
|
from core.models import AnonymousUser, Group, User
|
||||||
@@ -214,8 +214,7 @@ class TestNewsCreation(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
cls.user = subscriber_user.make()
|
cls.user = subscriber_user.make()
|
||||||
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
baker.make(Membership, user=cls.user, club=cls.club, role=5)
|
||||||
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|||||||
+1
-1
@@ -503,7 +503,7 @@ class WeekmailArticleCreateView(CreateView):
|
|||||||
self.object = form.instance
|
self.object = form.instance
|
||||||
form.is_valid() # Valid a first time to populate club field
|
form.is_valid() # Valid a first time to populate club field
|
||||||
m = form.instance.club.get_membership_for(request.user)
|
m = form.instance.club.get_membership_for(request.user)
|
||||||
if m is None or not m.role.is_board:
|
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
||||||
form.add_error(
|
form.add_error(
|
||||||
"club",
|
"club",
|
||||||
ValidationError(
|
ValidationError(
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import localdate, now
|
from django.utils.timezone import localdate, now
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe, foreign_key, related
|
from model_bakery.recipe import Recipe, related
|
||||||
|
|
||||||
from club.models import ClubRole, Membership
|
from club.models import Membership
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
@@ -52,9 +52,7 @@ ae_board_membership = Recipe(
|
|||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club_id=settings.SITH_MAIN_CLUB_ID,
|
club_id=settings.SITH_MAIN_CLUB_ID,
|
||||||
role=foreign_key(
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
board_user = Recipe(
|
board_user = Recipe(
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from django.utils import timezone
|
|||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from com.ics_calendar import IcsCalendar
|
from com.ics_calendar import IcsCalendar
|
||||||
from com.models import News, NewsDate, Sith, Weekmail
|
from com.models import News, NewsDate, Sith, Weekmail
|
||||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||||
@@ -69,13 +69,6 @@ class PopulatedGroups(NamedTuple):
|
|||||||
campus_admin: Group
|
campus_admin: Group
|
||||||
|
|
||||||
|
|
||||||
class PopulatedClubs(NamedTuple):
|
|
||||||
ae: Club
|
|
||||||
troll: Club
|
|
||||||
pdf: Club
|
|
||||||
refound: Club
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||||
@@ -127,16 +120,32 @@ class Command(BaseCommand):
|
|||||||
sas = SithFile.objects.create(
|
sas = SithFile.objects.create(
|
||||||
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
|
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
|
||||||
)
|
)
|
||||||
clubs = self._create_clubs()
|
main_club = Club.objects.create(
|
||||||
|
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||||
|
)
|
||||||
|
main_club.board_group.permissions.add(
|
||||||
|
*Permission.objects.filter(
|
||||||
|
codename__in=[
|
||||||
|
"view_subscription",
|
||||||
|
"add_subscription",
|
||||||
|
"view_hidden_user",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bar_club = Club.objects.create(
|
||||||
|
id=settings.SITH_PDF_CLUB_ID,
|
||||||
|
name="PdF",
|
||||||
|
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||||
|
)
|
||||||
|
|
||||||
self.reset_index("club")
|
self.reset_index("club")
|
||||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||||
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
|
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
|
||||||
self.reset_index("counter")
|
self.reset_index("counter")
|
||||||
counters = [
|
counters = [
|
||||||
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
|
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
||||||
Counter(name="AE", club=clubs.ae, type="OFFICE"),
|
Counter(name="AE", club=main_club, type="OFFICE"),
|
||||||
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
|
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
||||||
]
|
]
|
||||||
Counter.objects.bulk_create(counters)
|
Counter.objects.bulk_create(counters)
|
||||||
bar_groups = []
|
bar_groups = []
|
||||||
@@ -319,49 +328,62 @@ class Command(BaseCommand):
|
|||||||
self._create_subscription(tutu)
|
self._create_subscription(tutu)
|
||||||
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
||||||
|
|
||||||
Membership.objects.create(
|
# Clubs
|
||||||
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
|
Club.objects.create(
|
||||||
|
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
|
||||||
)
|
)
|
||||||
|
guyut = Club.objects.create(
|
||||||
|
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
|
||||||
|
)
|
||||||
|
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
|
||||||
|
troll = Club.objects.create(
|
||||||
|
name="Troll Penché", address="Terre Du Milieu", parent=main_club
|
||||||
|
)
|
||||||
|
refound = Club.objects.create(
|
||||||
|
name="Carte AE", address="Jamais imprimée", parent=main_club
|
||||||
|
)
|
||||||
|
|
||||||
|
Membership.objects.create(user=skia, club=main_club, role=3)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=comunity,
|
user=comunity,
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
start_date=localdate(),
|
start_date=localdate(),
|
||||||
role=clubs.pdf.roles.get(name="Membre du bureau"),
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=sli,
|
user=sli,
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
|
role=9,
|
||||||
description="Padawan Troll",
|
description="Padawan Troll",
|
||||||
start_date=localdate() - timedelta(days=17),
|
start_date=localdate() - timedelta(days=17),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=krophil,
|
user=krophil,
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
role=clubs.troll.roles.get(name="Président⸱e"),
|
role=10,
|
||||||
description="Maitre Troll",
|
description="Maitre Troll",
|
||||||
start_date=localdate() - timedelta(days=200),
|
start_date=localdate() - timedelta(days=200),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=skia,
|
user=skia,
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
role=clubs.troll.roles.get(name="Membre du bureau"),
|
role=2,
|
||||||
description="Grand Ancien Troll",
|
description="Grand Ancien Troll",
|
||||||
start_date=localdate() - timedelta(days=400),
|
start_date=localdate() - timedelta(days=400),
|
||||||
end_date=localdate() - timedelta(days=86),
|
end_date=localdate() - timedelta(days=86),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=richard,
|
user=richard,
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
role=clubs.troll.roles.get(name="Membre du bureau"),
|
role=2,
|
||||||
description="",
|
description="",
|
||||||
start_date=localdate() - timedelta(days=200),
|
start_date=localdate() - timedelta(days=200),
|
||||||
end_date=localdate() - timedelta(days=100),
|
end_date=localdate() - timedelta(days=100),
|
||||||
)
|
)
|
||||||
|
|
||||||
self._create_products(groups, clubs)
|
self._create_products(groups, main_club, refound)
|
||||||
|
|
||||||
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
||||||
|
|
||||||
# Add barman to counter
|
# Add barman to counter
|
||||||
Counter.sellers.through.objects.bulk_create(
|
Counter.sellers.through.objects.bulk_create(
|
||||||
@@ -381,7 +403,7 @@ class Command(BaseCommand):
|
|||||||
end_date="7942-06-12 10:28:45+01",
|
end_date="7942-06-12 10:28:45+01",
|
||||||
)
|
)
|
||||||
el.view_groups.add(groups.public)
|
el.view_groups.add(groups.public)
|
||||||
el.edit_groups.add(clubs.ae.board_group)
|
el.edit_groups.add(main_club.board_group)
|
||||||
el.candidature_groups.add(groups.subscribers)
|
el.candidature_groups.add(groups.subscribers)
|
||||||
el.vote_groups.add(groups.subscribers)
|
el.vote_groups.add(groups.subscribers)
|
||||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||||
@@ -454,7 +476,7 @@ class Command(BaseCommand):
|
|||||||
title="Apero barman",
|
title="Apero barman",
|
||||||
summary="Viens boire un coup avec les barmans",
|
summary="Viens boire un coup avec les barmans",
|
||||||
content="Glou glou glou glou glou glou glou",
|
content="Glou glou glou glou glou glou glou",
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -472,7 +494,7 @@ class Command(BaseCommand):
|
|||||||
content=(
|
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/"
|
||||||
),
|
),
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -488,7 +510,7 @@ class Command(BaseCommand):
|
|||||||
title="Repas fromager",
|
title="Repas fromager",
|
||||||
summary="Wien manger du l'bon fromeug'",
|
summary="Wien manger du l'bon fromeug'",
|
||||||
content="Fô viendre mangey d'la bonne fondue!",
|
content="Fô viendre mangey d'la bonne fondue!",
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -504,7 +526,7 @@ class Command(BaseCommand):
|
|||||||
title="SdF",
|
title="SdF",
|
||||||
summary="Enjoy la fin des finaux!",
|
summary="Enjoy la fin des finaux!",
|
||||||
content="Viens faire la fête avec tout plein de gens!",
|
content="Viens faire la fête avec tout plein de gens!",
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -522,7 +544,7 @@ class Command(BaseCommand):
|
|||||||
summary="Viens jouer!",
|
summary="Viens jouer!",
|
||||||
content="Rejoins la fine équipe du Troll Penché et viens "
|
content="Rejoins la fine équipe du Troll Penché et viens "
|
||||||
"t'amuser le Vendredi soir!",
|
"t'amuser le Vendredi soir!",
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -600,7 +622,8 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
pict.file.name = p.name
|
pict.file.name = p.name
|
||||||
pict.full_clean()
|
pict.full_clean()
|
||||||
pict.generate_thumbnails(save=True)
|
pict.generate_thumbnails()
|
||||||
|
pict.save()
|
||||||
|
|
||||||
img_skia = Picture.objects.get(name="skia.jpg")
|
img_skia = Picture.objects.get(name="skia.jpg")
|
||||||
img_sli = Picture.objects.get(name="sli.jpg")
|
img_sli = Picture.objects.get(name="sli.jpg")
|
||||||
@@ -622,7 +645,9 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
|
def _create_products(
|
||||||
|
self, groups: PopulatedGroups, main_club: Club, refound_club: Club
|
||||||
|
):
|
||||||
beers_type, cotis_type, refill_type, verre_type = (
|
beers_type, cotis_type, refill_type, verre_type = (
|
||||||
ProductType.objects.bulk_create(
|
ProductType.objects.bulk_create(
|
||||||
[
|
[
|
||||||
@@ -638,28 +663,28 @@ class Command(BaseCommand):
|
|||||||
code="1SCOTIZ",
|
code="1SCOTIZ",
|
||||||
product_type=cotis_type,
|
product_type=cotis_type,
|
||||||
purchase_price=15,
|
purchase_price=15,
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
)
|
)
|
||||||
cotis2 = Product.objects.create(
|
cotis2 = Product.objects.create(
|
||||||
name="Cotis 2 semestres",
|
name="Cotis 2 semestres",
|
||||||
code="2SCOTIZ",
|
code="2SCOTIZ",
|
||||||
product_type=cotis_type,
|
product_type=cotis_type,
|
||||||
purchase_price="28",
|
purchase_price="28",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
)
|
)
|
||||||
refill = Product.objects.create(
|
refill = Product.objects.create(
|
||||||
name="Rechargement 15 €",
|
name="Rechargement 15 €",
|
||||||
code="15REFILL",
|
code="15REFILL",
|
||||||
product_type=refill_type,
|
product_type=refill_type,
|
||||||
purchase_price=15,
|
purchase_price=15,
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
)
|
)
|
||||||
barb = Product.objects.create(
|
barb = Product.objects.create(
|
||||||
name="Barbar",
|
name="Barbar",
|
||||||
code="BARB",
|
code="BARB",
|
||||||
product_type=beers_type,
|
product_type=beers_type,
|
||||||
purchase_price="1.50",
|
purchase_price="1.50",
|
||||||
club=clubs.pdf,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
cble = Product.objects.create(
|
cble = Product.objects.create(
|
||||||
@@ -667,7 +692,7 @@ class Command(BaseCommand):
|
|||||||
code="CBLE",
|
code="CBLE",
|
||||||
product_type=beers_type,
|
product_type=beers_type,
|
||||||
purchase_price="1.50",
|
purchase_price="1.50",
|
||||||
club=clubs.pdf,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
cons = Product.objects.create(
|
cons = Product.objects.create(
|
||||||
@@ -675,21 +700,21 @@ class Command(BaseCommand):
|
|||||||
code="CONS",
|
code="CONS",
|
||||||
product_type=verre_type,
|
product_type=verre_type,
|
||||||
purchase_price="1",
|
purchase_price="1",
|
||||||
club=clubs.pdf,
|
club=main_club,
|
||||||
)
|
)
|
||||||
dcons = Product.objects.create(
|
dcons = Product.objects.create(
|
||||||
name="Déconsigne Eco-cup",
|
name="Déconsigne Eco-cup",
|
||||||
code="DECO",
|
code="DECO",
|
||||||
product_type=verre_type,
|
product_type=verre_type,
|
||||||
purchase_price="-1",
|
purchase_price="-1",
|
||||||
club=clubs.pdf,
|
club=main_club,
|
||||||
)
|
)
|
||||||
cors = Product.objects.create(
|
cors = Product.objects.create(
|
||||||
name="Corsendonk",
|
name="Corsendonk",
|
||||||
code="CORS",
|
code="CORS",
|
||||||
product_type=beers_type,
|
product_type=beers_type,
|
||||||
purchase_price="1.50",
|
purchase_price="1.50",
|
||||||
club=clubs.pdf,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
carolus = Product.objects.create(
|
carolus = Product.objects.create(
|
||||||
@@ -697,14 +722,14 @@ class Command(BaseCommand):
|
|||||||
code="CARO",
|
code="CARO",
|
||||||
product_type=beers_type,
|
product_type=beers_type,
|
||||||
purchase_price="1.50",
|
purchase_price="1.50",
|
||||||
club=clubs.pdf,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
Product.objects.create(
|
Product.objects.create(
|
||||||
name="remboursement",
|
name="remboursement",
|
||||||
code="REMBOURS",
|
code="REMBOURS",
|
||||||
purchase_price=0,
|
purchase_price=0,
|
||||||
club=clubs.refound,
|
club=refound_club,
|
||||||
)
|
)
|
||||||
ReturnableProduct.objects.create(
|
ReturnableProduct.objects.create(
|
||||||
product=cons, returned_product=dcons, max_return=3
|
product=cons, returned_product=dcons, max_return=3
|
||||||
@@ -781,57 +806,6 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
s.save()
|
s.save()
|
||||||
|
|
||||||
def _create_clubs(self) -> PopulatedClubs:
|
|
||||||
ae = Club.objects.create(
|
|
||||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
|
||||||
)
|
|
||||||
ae.board_group.permissions.add(
|
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=[
|
|
||||||
"view_subscription",
|
|
||||||
"add_subscription",
|
|
||||||
"add_membership",
|
|
||||||
"view_hidden_user",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pdf = Club.objects.create(
|
|
||||||
id=settings.SITH_PDF_CLUB_ID,
|
|
||||||
name="PdF",
|
|
||||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
|
||||||
)
|
|
||||||
troll = Club.objects.create(
|
|
||||||
name="Troll Penché", address="Terre Du Milieu", parent=ae
|
|
||||||
)
|
|
||||||
refound = Club.objects.create(
|
|
||||||
name="Carte AE", address="Jamais imprimée", parent=ae
|
|
||||||
)
|
|
||||||
roles = []
|
|
||||||
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
|
|
||||||
board_roles = [
|
|
||||||
"Trésorier⸱e",
|
|
||||||
"Secrétaire",
|
|
||||||
"Respo Info",
|
|
||||||
"Respo Com",
|
|
||||||
"Membre du bureau",
|
|
||||||
]
|
|
||||||
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
|
|
||||||
for club in ae, pdf, troll, refound:
|
|
||||||
for i, role in enumerate(presidency_roles):
|
|
||||||
roles.append(
|
|
||||||
ClubRole(
|
|
||||||
club=club, order=i, name=role, is_presidency=True, is_board=True
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for i, role in enumerate(board_roles, start=len(presidency_roles)):
|
|
||||||
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
|
|
||||||
for i, role in enumerate(
|
|
||||||
simple_roles, start=len(presidency_roles) + len(board_roles)
|
|
||||||
):
|
|
||||||
roles.append(ClubRole(club=club, order=i, name=role))
|
|
||||||
ClubRole.objects.bulk_create(roles)
|
|
||||||
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
|
|
||||||
|
|
||||||
def _create_groups(self) -> PopulatedGroups:
|
def _create_groups(self) -> PopulatedGroups:
|
||||||
perms = Permission.objects.all()
|
perms = Permission.objects.all()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
|
|||||||
from django.utils.timezone import localdate, make_aware, now
|
from django.utils.timezone import localdate, make_aware, now
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.models import Group, User, UserBan
|
from core.models import Group, User, UserBan
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
@@ -173,25 +173,20 @@ class Command(BaseCommand):
|
|||||||
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
||||||
|
|
||||||
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
||||||
roles: list[ClubRole] = list(club.roles.all())
|
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
|
||||||
|
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
|
||||||
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
|
|
||||||
important_roles = [r for r in roles if r.is_board]
|
|
||||||
important_roles.sort(key=lambda r: r.order)
|
|
||||||
simple_board_role = important_roles.pop()
|
|
||||||
member_roles = [r for r in roles if not r.is_board]
|
|
||||||
user_idx = 0
|
user_idx = 0
|
||||||
for _role in important_roles:
|
while (role := next(roles)) > 2:
|
||||||
# one member for each major role
|
# one member for each major role
|
||||||
yield users[user_idx], _role
|
yield users[user_idx], role
|
||||||
user_idx += 1
|
user_idx += 1
|
||||||
for _ in range(int(0.3 * (len(users) - user_idx))):
|
for _ in range(int(0.3 * (len(users) - user_idx))):
|
||||||
# 30% of the remaining in the board
|
# 30% of the remaining in the board
|
||||||
yield users[user_idx], simple_board_role
|
yield users[user_idx], 2
|
||||||
user_idx += 1
|
user_idx += 1
|
||||||
for remaining in users[user_idx + 1 :]:
|
for remaining in users[user_idx + 1 :]:
|
||||||
# everything else is a simple member
|
# everything else is a simple member
|
||||||
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
|
yield remaining, 1
|
||||||
|
|
||||||
memberships = []
|
memberships = []
|
||||||
old_members = old_members.copy()
|
old_members = old_members.copy()
|
||||||
@@ -203,14 +198,19 @@ class Command(BaseCommand):
|
|||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=self.faker.past_date(start),
|
end_date=self.faker.past_date(start),
|
||||||
user=old,
|
user=old,
|
||||||
role=random.choice(roles),
|
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
|
||||||
club=club,
|
club=club,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for member, role in zip_roles(members):
|
for member, role in zip_roles(members):
|
||||||
start = self.faker.past_date("-1y")
|
start = self.faker.past_date("-1y")
|
||||||
memberships.append(
|
memberships.append(
|
||||||
Membership(start_date=start, user=member, role=role, club=club)
|
Membership(
|
||||||
|
start_date=start,
|
||||||
|
user=member,
|
||||||
|
role=role,
|
||||||
|
club=club,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
memberships = Membership.objects.bulk_create(memberships)
|
memberships = Membership.objects.bulk_create(memberships)
|
||||||
Membership._add_club_groups(memberships)
|
Membership._add_club_groups(memberships)
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# Generated by Django 5.2.12 on 2026-05-01 08:59
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.db.migrations.state import StateApps
|
|
||||||
from django.db.models import F
|
|
||||||
|
|
||||||
|
|
||||||
def set_updated_at(apps: StateApps, schema_editor):
|
|
||||||
SithFile = apps.get_model("core", "SithFile")
|
|
||||||
SithFile.objects.update(updated_at=F("date"))
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("core", "0049_user_whitelisted_users")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="sithfile",
|
|
||||||
name="moderator",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name="moderated_files",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
verbose_name="owner",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="sithfile",
|
|
||||||
name="owner",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name="owned_files",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
verbose_name="owner",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="sithfile",
|
|
||||||
name="updated_at",
|
|
||||||
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
|
||||||
),
|
|
||||||
migrations.RunPython(set_updated_at, reverse_code=migrations.RunPython.noop),
|
|
||||||
]
|
|
||||||
+2
-3
@@ -853,7 +853,7 @@ class SithFile(models.Model):
|
|||||||
User,
|
User,
|
||||||
related_name="owned_files",
|
related_name="owned_files",
|
||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
edit_groups = models.ManyToManyField(
|
edit_groups = models.ManyToManyField(
|
||||||
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
|
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
|
||||||
@@ -865,7 +865,6 @@ class SithFile(models.Model):
|
|||||||
mime_type = models.CharField(_("mime type"), max_length=30)
|
mime_type = models.CharField(_("mime type"), max_length=30)
|
||||||
size = models.IntegerField(_("size"), default=0)
|
size = models.IntegerField(_("size"), default=0)
|
||||||
date = models.DateTimeField(_("date"), default=timezone.now)
|
date = models.DateTimeField(_("date"), default=timezone.now)
|
||||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
|
||||||
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
||||||
moderator = models.ForeignKey(
|
moderator = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
@@ -873,7 +872,7 @@ class SithFile(models.Model):
|
|||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
||||||
is_in_sas = models.BooleanField(
|
is_in_sas = models.BooleanField(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class Tab extends HTMLElement {
|
|||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
?aria-selected="${this.active}"
|
?aria-selected=${this.active}
|
||||||
class="tab-header clickable ${this.active ? "active" : ""}"
|
class="tab-header clickable ${this.active ? "active" : ""}"
|
||||||
@click="${() => this.setActive(true)}"
|
@click="${() => this.setActive(true)}"
|
||||||
>
|
>
|
||||||
@@ -40,7 +40,7 @@ export class Tab extends HTMLElement {
|
|||||||
return html`
|
return html`
|
||||||
<section
|
<section
|
||||||
class="tab-section"
|
class="tab-section"
|
||||||
?hidden="${!this.active}"
|
?hidden=${!this.active}
|
||||||
>
|
>
|
||||||
${unsafeHTML(this.getContentHtml())}
|
${unsafeHTML(this.getContentHtml())}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -47,18 +47,9 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
|||||||
implements InheritedHtmlElement<K>
|
implements InheritedHtmlElement<K>
|
||||||
{
|
{
|
||||||
readonly inheritedTagName = tagName;
|
readonly inheritedTagName = tagName;
|
||||||
private readonly initializedAttribute = "component-initialized";
|
|
||||||
node: HTMLElementTagNameMap[K];
|
node: HTMLElementTagNameMap[K];
|
||||||
|
|
||||||
connectedCallback(autoAddNode?: boolean) {
|
connectedCallback(autoAddNode?: boolean) {
|
||||||
// When nesting inherited elements, we might trigger the wrapping twice
|
|
||||||
// To avoid this, we tag a created element as initialized
|
|
||||||
// We then skip the initialization step and grab the inner content as the node
|
|
||||||
if (this.hasAttribute(this.initializedAttribute)) {
|
|
||||||
this.node = this.firstChild as HTMLElementTagNameMap[K];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.node = document.createElement(this.inheritedTagName);
|
this.node = document.createElement(this.inheritedTagName);
|
||||||
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
||||||
for (const attr of this.attributes) {
|
for (const attr of this.attributes) {
|
||||||
@@ -67,8 +58,6 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setAttribute(this.initializedAttribute, "");
|
|
||||||
|
|
||||||
// We move compatible attributes to the child element
|
// We move compatible attributes to the child element
|
||||||
// This avoids weird inconsistencies between attributes
|
// This avoids weird inconsistencies between attributes
|
||||||
// when we manipulate the dom in the future
|
// when we manipulate the dom in the future
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ body {
|
|||||||
|
|
||||||
/*--------------------------------CONTENT------------------------------*/
|
/*--------------------------------CONTENT------------------------------*/
|
||||||
#content {
|
#content {
|
||||||
padding: 1.5em 3%;
|
padding: 1em 1%;
|
||||||
box-shadow: $shadow-color 0 5px 10px;
|
box-shadow: $shadow-color 0 5px 10px;
|
||||||
background: $white-color;
|
background: $white-color;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<div id="quick-notifications"
|
<div id="quick-notifications"
|
||||||
x-data='{
|
x-data="{
|
||||||
messages: [
|
messages: [
|
||||||
{%- for message in messages -%}
|
{%- for message in messages -%}
|
||||||
{%- if not message.extra_tags -%}
|
{%- if not message.extra_tags -%}
|
||||||
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} },
|
{ tag: '{{ message.tags }}', text: '{{ message }}' },
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
]
|
]
|
||||||
}'
|
}"
|
||||||
@quick-notification-add="(e) => messages.push(e?.detail)"
|
@quick-notification-add="(e) => messages.push(e?.detail)"
|
||||||
@quick-notification-delete="messages = []">
|
@quick-notification-delete="messages = []">
|
||||||
<template x-for="(message, index) in messages">
|
<template x-for="(message, index) in messages">
|
||||||
|
|||||||
@@ -33,8 +33,7 @@
|
|||||||
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
|
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
|
||||||
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
|
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
|
||||||
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
||||||
{% trans %}Date: {% endtrans %}
|
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
||||||
{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
|
||||||
</p>
|
</p>
|
||||||
<p><button
|
<p><button
|
||||||
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
|
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<span class="helptext">{{ form.is_viewable.help_text }}</span>
|
<span class="helptext">{{ form.is_viewable.help_text }}</span>
|
||||||
{{ form.is_viewable.errors }}
|
{{ form.is_viewable.errors }}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="form-group" x-show="!isViewable" x-transition x-cloak>
|
<fieldset class="form-group" x-show="!isViewable">
|
||||||
{{ form.whitelisted_users.as_field_group() }}
|
{{ form.whitelisted_users.as_field_group() }}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="form-group">
|
<fieldset class="form-group">
|
||||||
|
|||||||
@@ -23,10 +23,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for m in profile.memberships.ongoing().select_related("role") %}
|
{% for m in profile.memberships.filter(end_date=None).all() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||||
<td>{{ m.role.name }}</td>
|
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
{% if m.can_be_edited_by(user) %}
|
{% if m.can_be_edited_by(user) %}
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for m in profile.memberships.exclude(end_date=None).select_related("role") %}
|
{% for m in profile.memberships.exclude(end_date=None).all() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||||
<td>{{ m.role.name }}</td>
|
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
<td>{{ m.end_date }}</td>
|
<td>{{ m.end_date }}</td>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<script-once type="module" src="{{ js }}"></script-once>
|
<script-once type="module" src="{{ js }}"></script-once>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for css in statics.css %}
|
{% for css in statics.css %}
|
||||||
<link-once rel="stylesheet" type="text/css" href="{{ css }}"></link-once>
|
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
|
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import AnonymousUser, Page, PageRev, User
|
from core.models import AnonymousUser, Page, PageRev, User
|
||||||
@@ -122,9 +122,6 @@ def test_page_revision_club_redirection(client: Client):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_viewable_by():
|
def test_viewable_by():
|
||||||
# remove existing pages to prevent side effect
|
# remove existing pages to prevent side effect
|
||||||
# club pages are protected, so we must delete clubs first
|
|
||||||
Membership.objects.all().delete()
|
|
||||||
Club.objects.all().delete()
|
|
||||||
Page.objects.all().delete()
|
Page.objects.all().delete()
|
||||||
view_groups = [
|
view_groups = [
|
||||||
[settings.SITH_GROUP_PUBLIC_ID],
|
[settings.SITH_GROUP_PUBLIC_ID],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from core.baker_recipes import (
|
|||||||
subscriber_user,
|
subscriber_user,
|
||||||
very_old_subscriber_user,
|
very_old_subscriber_user,
|
||||||
)
|
)
|
||||||
from core.models import AnonymousUser, Group, SithFile, User
|
from core.models import AnonymousUser, Group, User
|
||||||
from core.views import UserTabsMixin
|
from core.views import UserTabsMixin
|
||||||
from counter.baker_recipes import sale_recipe
|
from counter.baker_recipes import sale_recipe
|
||||||
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
||||||
@@ -34,7 +34,6 @@ class TestSearchUsers(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
||||||
News.objects.all().delete()
|
News.objects.all().delete()
|
||||||
SithFile.objects.all().delete()
|
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
user_recipe = Recipe(
|
user_recipe = Recipe(
|
||||||
User,
|
User,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from django.core.files.base import ContentFile
|
|||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
|
from PIL import ExifTags
|
||||||
from PIL.Image import Image, Resampling
|
from PIL.Image import Image, Resampling
|
||||||
|
|
||||||
RED_PIXEL_PNG: Final[bytes] = (
|
RED_PIXEL_PNG: Final[bytes] = (
|
||||||
@@ -177,6 +178,22 @@ def resize_image_explicit(
|
|||||||
return ContentFile(content.getvalue())
|
return ContentFile(content.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def exif_auto_rotate(image):
|
||||||
|
for orientation in ExifTags.TAGS:
|
||||||
|
if ExifTags.TAGS[orientation] == "Orientation":
|
||||||
|
break
|
||||||
|
exif = dict(image._getexif().items())
|
||||||
|
|
||||||
|
if exif[orientation] == 3:
|
||||||
|
image = image.rotate(180, expand=True)
|
||||||
|
elif exif[orientation] == 6:
|
||||||
|
image = image.rotate(270, expand=True)
|
||||||
|
elif exif[orientation] == 8:
|
||||||
|
image = image.rotate(90, expand=True)
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request: HttpRequest) -> str | None:
|
def get_client_ip(request: HttpRequest) -> str | None:
|
||||||
headers = (
|
headers = (
|
||||||
"X_FORWARDED_FOR", # Common header for proxies
|
"X_FORWARDED_FOR", # Common header for proxies
|
||||||
|
|||||||
+2
-2
@@ -431,7 +431,7 @@ class PriceQuerySet(models.QuerySet):
|
|||||||
),
|
),
|
||||||
product__archived=False,
|
product__archived=False,
|
||||||
product__limit_age__lte=age,
|
product__limit_age__lte=age,
|
||||||
).distinct()
|
)
|
||||||
|
|
||||||
|
|
||||||
class Price(models.Model):
|
class Price(models.Model):
|
||||||
@@ -600,7 +600,7 @@ class Counter(models.Model):
|
|||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
mem = self.club.get_membership_for(user)
|
mem = self.club.get_membership_for(user)
|
||||||
if mem and mem.role.is_presidency:
|
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||||
return True
|
return True
|
||||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from model_bakery import baker
|
|||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import ClubRole, Membership
|
from club.models import Membership
|
||||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||||
from core.models import BanGroup, Group, User
|
from core.models import BanGroup, Group, User
|
||||||
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
|
||||||
@@ -87,7 +87,7 @@ class TestFullClickBase(TestCase):
|
|||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club=cls.club_counter.club,
|
club=cls.club_counter.club,
|
||||||
role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True),
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
user=cls.club_admin,
|
user=cls.club_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -800,13 +800,7 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
"counter:click",
|
"counter:click",
|
||||||
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
|
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
|
||||||
)
|
)
|
||||||
cls.board_role, cls.member_role = baker.make(
|
|
||||||
ClubRole,
|
|
||||||
club=cls.counter.club,
|
|
||||||
is_board=iter([True, False]),
|
|
||||||
_quantity=2,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
cls.user = subscriber_user.make()
|
cls.user = subscriber_user.make()
|
||||||
|
|
||||||
def test_anonymous(self):
|
def test_anonymous(self):
|
||||||
@@ -818,17 +812,13 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
# being a member of the club, without being in the board, isn't enough
|
# being a member of the club, without being in the board, isn't enough
|
||||||
baker.make(
|
baker.make(Membership, club=self.counter.club, user=self.user, role=1)
|
||||||
Membership, club=self.counter.club, user=self.user, role=self.member_role
|
|
||||||
)
|
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
|
|
||||||
def test_board_member(self):
|
def test_board_member(self):
|
||||||
"""By default, board members should be able to click on office counters"""
|
"""By default, board members should be able to click on office counters"""
|
||||||
baker.make(
|
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
||||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
|
||||||
)
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -843,9 +833,7 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
def test_both_barman_and_board_member(self):
|
def test_both_barman_and_board_member(self):
|
||||||
"""If the user is barman and board member, he should be authorized as well."""
|
"""If the user is barman and board member, he should be authorized as well."""
|
||||||
self.counter.sellers.add(self.user)
|
self.counter.sellers.add(self.user)
|
||||||
baker.make(
|
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
||||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
|
||||||
)
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import string
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.base_user import make_password
|
from django.contrib.auth.base_user import make_password
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
from club.models import ClubRole, Membership
|
from club.models import Membership
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||||
@@ -41,12 +42,11 @@ class TestStudentCard(TestCase):
|
|||||||
cls.counter.sellers.add(cls.barmen)
|
cls.counter.sellers.add(cls.barmen)
|
||||||
|
|
||||||
cls.club_counter = baker.make(Counter)
|
cls.club_counter = baker.make(Counter)
|
||||||
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
|
||||||
baker.make(
|
baker.make(
|
||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club=cls.club_counter.club,
|
club=cls.club_counter.club,
|
||||||
role=role,
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
user=cls.club_admin,
|
user=cls.club_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,6 @@ def test_price_for_user():
|
|||||||
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
|
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
|
||||||
]
|
]
|
||||||
qs = Price.objects.order_by("-amount")
|
qs = Price.objects.order_by("-amount")
|
||||||
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
|
assert set(qs.for_user(users[0])) == {prices[0], prices[1], prices[4]}
|
||||||
assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
|
assert set(qs.for_user(users[1])) == {prices[0], prices[4]}
|
||||||
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
|
assert set(qs.for_user(users[2])) == {prices[0], prices[3]}
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.basket = [];
|
this.basket = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document
|
|
||||||
.getElementById("id_form-TOTAL_FORMS")
|
// It's quite tricky to manually apply attributes to the management part
|
||||||
|
// of a formset so we dynamically apply it here
|
||||||
|
this.$refs.basketManagementForm
|
||||||
|
.getElementById("#id_form-TOTAL_FORMS")
|
||||||
.setAttribute(":value", "basket.length");
|
.setAttribute(":value", "basket.length");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
#eboutic {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
align-items: flex-start;
|
||||||
|
column-gap: 20px;
|
||||||
|
margin: 0 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic-title {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic h3 {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#basket {
|
||||||
|
min-width: 300px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow:
|
||||||
|
rgb(60 64 67 / 30%) 0 1px 3px 0,
|
||||||
|
rgb(60 64 67 / 15%) 0 4px 8px 3px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#basket h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 765px) {
|
||||||
|
#eboutic {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: center;
|
||||||
|
margin: 10px;
|
||||||
|
row-gap: 20px;
|
||||||
|
}
|
||||||
|
#eboutic-title {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
#basket {
|
||||||
|
width: -webkit-fill-available;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .item-list {
|
||||||
|
margin-left: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .item-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .item-row {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .item-name {
|
||||||
|
word-break: break-word;
|
||||||
|
width: 100%;
|
||||||
|
line-height: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .fa-plus,
|
||||||
|
#eboutic .fa-minus {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #354a5f;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .item-quantity {
|
||||||
|
min-width: 65px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .item-price {
|
||||||
|
min-width: 65px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS du catalogue */
|
||||||
|
|
||||||
|
#eboutic #catalog {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .category-header {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
column-gap: 15px;
|
||||||
|
row-gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .card.selected::after {
|
||||||
|
content: "🛒";
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%);
|
||||||
|
background-color: white;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .catalog-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
column-gap: 30px;
|
||||||
|
margin: 30px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic input {
|
||||||
|
all: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .catalog-buttons button {
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .catalog-buttons form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 765px) {
|
||||||
|
#eboutic #catalog {
|
||||||
|
row-gap: 15px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic section {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eboutic .product-group {
|
||||||
|
justify-content: space-around;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
#eboutic-title {
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#eboutic {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
align-items: flex-start;
|
|
||||||
column-gap: 20px;
|
|
||||||
margin: 0 20px 20px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#basket {
|
|
||||||
--box-shadow:
|
|
||||||
rgb(60 64 67 / 30%) 0 1px 3px 0,
|
|
||||||
rgb(60 64 67 / 15%) 0 4px 8px 3px;
|
|
||||||
min-width: 300px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: var(--box-shadow);
|
|
||||||
padding: 10px;
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 765px) {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
align-items: center;
|
|
||||||
margin: 10px;
|
|
||||||
row-gap: 20px;
|
|
||||||
|
|
||||||
#eboutic-title {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
#basket {
|
|
||||||
width: -webkit-fill-available;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-list {
|
|
||||||
margin-left: 0;
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-row {
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-name {
|
|
||||||
word-break: break-word;
|
|
||||||
width: 100%;
|
|
||||||
line-height: 100%;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa-plus,
|
|
||||||
.fa-minus {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: #354a5f;
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
padding: 5px;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 10px;
|
|
||||||
width: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-quantity {
|
|
||||||
min-width: 65px;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-price {
|
|
||||||
min-width: 65px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CSS du catalogue */
|
|
||||||
|
|
||||||
#catalog {
|
|
||||||
display: flex;
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-header {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-group {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
column-gap: 15px;
|
|
||||||
row-gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card.selected::after {
|
|
||||||
--box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%);
|
|
||||||
content: "🛒";
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
right: 5px;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: var(--box-shadow);
|
|
||||||
background-color: white;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
all: unset;
|
|
||||||
}
|
|
||||||
.catalog-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
column-gap: 30px;
|
|
||||||
margin: 30px 0 0;
|
|
||||||
|
|
||||||
button {
|
|
||||||
min-width: 60px;
|
|
||||||
}
|
|
||||||
form {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@media screen and (max-width: 765px) {
|
|
||||||
#catalog {
|
|
||||||
row-gap: 15px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-group {
|
|
||||||
justify-content: space-around;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,14 +8,6 @@
|
|||||||
{% trans %}The online shop of the association.{% endtrans %}
|
{% trans %}The online shop of the association.{% endtrans %}
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
|
||||||
{% block metatags %}
|
|
||||||
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:title" content="Eboutic" />
|
|
||||||
<meta property="og:description" content="La boutique en ligne de l'AE" />
|
|
||||||
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
{# This script contains the code to perform requests to manipulate the
|
{# This script contains the code to perform requests to manipulate the
|
||||||
user basket without having to reload the page #}
|
user basket without having to reload the page #}
|
||||||
@@ -23,7 +15,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_css %}
|
{% block additional_css %}
|
||||||
<link rel="stylesheet" href="{{ static("eboutic/css/eboutic.scss") }}">
|
<link rel="stylesheet" href="{{ static("eboutic/css/eboutic.css") }}">
|
||||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -183,7 +183,7 @@ class Forum(models.Model):
|
|||||||
Forum._club_memberships[self.id] = {}
|
Forum._club_memberships[self.id] = {}
|
||||||
Forum._club_memberships[self.id][user.id] = m
|
Forum._club_memberships[self.id][user.id] = m
|
||||||
if m:
|
if m:
|
||||||
return m.role.is_board
|
return m.role > settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def check_loop(self):
|
def check_loop(self):
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ from django.conf import settings
|
|||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from model_bakery import baker
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.models import Group, Page, SithFile, User
|
from core.models import Group, Page, SithFile, User
|
||||||
from core.utils import RED_PIXEL_PNG
|
from core.utils import RED_PIXEL_PNG
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
@@ -218,19 +217,11 @@ class Command(BaseCommand):
|
|||||||
"The `make_clubs()` method must be called before `make_club_memberships()`"
|
"The `make_clubs()` method must be called before `make_club_memberships()`"
|
||||||
)
|
)
|
||||||
memberships = []
|
memberships = []
|
||||||
roles = {
|
|
||||||
r.club_id: r.id
|
|
||||||
for r in baker.make(
|
|
||||||
ClubRole,
|
|
||||||
club=iter(self.clubs),
|
|
||||||
_quantity=len(self.clubs),
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for i in range(1, 11): # users can be in up to 20 clubs
|
for i in range(1, 11): # users can be in up to 20 clubs
|
||||||
self.logger.info(f"Club membership, pass {i}")
|
self.logger.info(f"Club membership, pass {i}")
|
||||||
for uid in range(i, self.NB_USERS, i):
|
for uid in range(
|
||||||
# Pass #1 will make sure every user is at least in one club
|
i, self.NB_USERS, i
|
||||||
|
): # Pass #1 will make sure every user is at least in one club
|
||||||
user = self.users[uid]
|
user = self.users[uid]
|
||||||
club = self.clubs[(uid + i**2) % self.NB_CLUBS]
|
club = self.clubs[(uid + i**2) % self.NB_CLUBS]
|
||||||
|
|
||||||
@@ -245,7 +236,7 @@ class Command(BaseCommand):
|
|||||||
Membership(
|
Membership(
|
||||||
user=user,
|
user=user,
|
||||||
club=club,
|
club=club,
|
||||||
role_id=roles[club.id],
|
role=(uid + i) % 10 + 1, # spread the different roles
|
||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=end,
|
end_date=end,
|
||||||
)
|
)
|
||||||
@@ -268,7 +259,7 @@ class Command(BaseCommand):
|
|||||||
Membership(
|
Membership(
|
||||||
user=user,
|
user=user,
|
||||||
club=club,
|
club=club,
|
||||||
role_id=roles[club.id],
|
role=((uid // 10) + i) % 10 + 1, # spread the different roles
|
||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=end,
|
end_date=end,
|
||||||
)
|
)
|
||||||
|
|||||||
+146
-137
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-05-12 09:48+0200\n"
|
"POT-Creation-Date: 2026-03-23 22:21+0100\n"
|
||||||
"PO-Revision-Date: 2016-07-18\n"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@@ -181,22 +181,6 @@ msgstr "Vous devez être cotisant pour faire partie d'un club"
|
|||||||
msgid "You are already a member of this club"
|
msgid "You are already a member of this club"
|
||||||
msgstr "Vous êtes déjà membre de ce club."
|
msgstr "Vous êtes déjà membre de ce club."
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Club status"
|
|
||||||
msgstr "État du club"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Active"
|
|
||||||
msgstr "Actif"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Inactive"
|
|
||||||
msgstr "Inactif"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "All clubs"
|
|
||||||
msgstr "Tous les clubs"
|
|
||||||
|
|
||||||
#: club/models.py
|
#: club/models.py
|
||||||
msgid "slug name"
|
msgid "slug name"
|
||||||
msgstr "nom slug"
|
msgstr "nom slug"
|
||||||
@@ -233,67 +217,15 @@ msgstr "home"
|
|||||||
msgid "You can not make loops in clubs"
|
msgid "You can not make loops in clubs"
|
||||||
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
|
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
|
||||||
|
|
||||||
#: club/models.py com/models.py counter/models.py trombi/models.py
|
|
||||||
msgid "club"
|
|
||||||
msgstr "club"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "The club with which this role is associated"
|
|
||||||
msgstr "Le club auquel ce rôle est attaché."
|
|
||||||
|
|
||||||
#: club/models.py core/models.py counter/models.py election/models.py
|
|
||||||
#: forum/models.py
|
|
||||||
msgid "description"
|
|
||||||
msgstr "description"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "Board role"
|
|
||||||
msgstr "Rôle du bureau"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "Presidency role"
|
|
||||||
msgstr "Rôle de la présidence"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid ""
|
|
||||||
"If the role is inactive, people joining the club won't be able to get it."
|
|
||||||
msgstr ""
|
|
||||||
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
|
|
||||||
"rejoignent le club."
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "club role"
|
|
||||||
msgstr "rôle de club"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "club roles"
|
|
||||||
msgstr "rôles de club"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
"Role %(name)s was declared as a presidency role without being a board role"
|
|
||||||
msgstr ""
|
|
||||||
"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du "
|
|
||||||
"bureau."
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
#, python-format
|
|
||||||
msgid "Role %(role)s cannot be placed below a member role"
|
|
||||||
msgstr ""
|
|
||||||
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre."
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
#, python-format
|
|
||||||
msgid "Role %(role)s cannot be placed below a non-presidency role"
|
|
||||||
msgstr ""
|
|
||||||
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre."
|
|
||||||
|
|
||||||
#: club/models.py core/models.py counter/models.py eboutic/models.py
|
#: club/models.py core/models.py counter/models.py eboutic/models.py
|
||||||
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
|
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "utilisateur"
|
msgstr "utilisateur"
|
||||||
|
|
||||||
|
#: club/models.py com/models.py counter/models.py trombi/models.py
|
||||||
|
msgid "club"
|
||||||
|
msgstr "club"
|
||||||
|
|
||||||
#: club/models.py counter/models.py election/models.py
|
#: club/models.py counter/models.py election/models.py
|
||||||
msgid "start date"
|
msgid "start date"
|
||||||
msgstr "date de début"
|
msgstr "date de début"
|
||||||
@@ -306,6 +238,11 @@ msgstr "date de fin"
|
|||||||
msgid "role"
|
msgid "role"
|
||||||
msgstr "rôle"
|
msgstr "rôle"
|
||||||
|
|
||||||
|
#: club/models.py core/models.py counter/models.py election/models.py
|
||||||
|
#: forum/models.py
|
||||||
|
msgid "description"
|
||||||
|
msgstr "description"
|
||||||
|
|
||||||
#: club/models.py
|
#: club/models.py
|
||||||
msgid "past member"
|
msgid "past member"
|
||||||
msgstr "ancien membre"
|
msgstr "ancien membre"
|
||||||
@@ -364,22 +301,37 @@ msgstr "Cet email est déjà abonné à cette mailing"
|
|||||||
msgid "Unregistered user"
|
msgid "Unregistered user"
|
||||||
msgstr "Utilisateur non enregistré"
|
msgstr "Utilisateur non enregistré"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
|
||||||
msgid "The list of all clubs existing at UTBM."
|
|
||||||
msgstr "La liste de tous les clubs existants à l'UTBM"
|
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
msgid "Club list"
|
msgid "Club list"
|
||||||
msgstr "Liste des clubs"
|
msgstr "Liste des clubs"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "The list of all clubs existing at UTBM."
|
||||||
|
msgstr "La liste de tous les clubs existants à l'UTBM"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
msgid "Filters"
|
msgid "Filters"
|
||||||
msgstr "Filtres"
|
msgstr "Filtres"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja
|
msgid "Name"
|
||||||
msgid "Search"
|
msgstr "Nom"
|
||||||
msgstr "Recherche"
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "Club state"
|
||||||
|
msgstr "Etat du club"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Actif"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "Inactive"
|
||||||
|
msgstr "Inactif"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "All clubs"
|
||||||
|
msgstr "Tous les clubs"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
|
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
|
||||||
msgid "New club"
|
msgid "New club"
|
||||||
@@ -481,7 +433,7 @@ msgstr "Bénéfice : "
|
|||||||
#: counter/templates/counter/cash_summary_list.jinja
|
#: counter/templates/counter/cash_summary_list.jinja
|
||||||
#: counter/templates/counter/last_ops.jinja
|
#: counter/templates/counter/last_ops.jinja
|
||||||
#: counter/templates/counter/refilling_list.jinja
|
#: counter/templates/counter/refilling_list.jinja
|
||||||
#: rootplace/templates/rootplace/logs.jinja
|
#: rootplace/templates/rootplace/logs.jinja sas/forms.py
|
||||||
#: trombi/templates/trombi/user_profile.jinja
|
#: trombi/templates/trombi/user_profile.jinja
|
||||||
msgid "Date"
|
msgid "Date"
|
||||||
msgstr "Date"
|
msgstr "Date"
|
||||||
@@ -629,8 +581,7 @@ msgstr ""
|
|||||||
#: counter/templates/counter/invoices_call.jinja
|
#: counter/templates/counter/invoices_call.jinja
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
#: forum/templates/forum/reply.jinja
|
#: forum/templates/forum/reply.jinja
|
||||||
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
|
#: subscription/templates/subscription/fragments/creation_form.jinja
|
||||||
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
|
|
||||||
#: trombi/templates/trombi/comment.jinja
|
#: trombi/templates/trombi/comment.jinja
|
||||||
#: trombi/templates/trombi/edit_profile.jinja
|
#: trombi/templates/trombi/edit_profile.jinja
|
||||||
#: trombi/templates/trombi/user_tools.jinja
|
#: trombi/templates/trombi/user_tools.jinja
|
||||||
@@ -1741,10 +1692,6 @@ msgstr "taille"
|
|||||||
msgid "date"
|
msgid "date"
|
||||||
msgstr "date"
|
msgstr "date"
|
||||||
|
|
||||||
#: core/models.py counter/models.py
|
|
||||||
msgid "updated at"
|
|
||||||
msgstr "mis à jour le"
|
|
||||||
|
|
||||||
#: core/models.py
|
#: core/models.py
|
||||||
msgid "asked for removal"
|
msgid "asked for removal"
|
||||||
msgstr "retrait demandé"
|
msgstr "retrait demandé"
|
||||||
@@ -1916,6 +1863,11 @@ msgstr "Connexion"
|
|||||||
msgid "Register"
|
msgid "Register"
|
||||||
msgstr "Inscription"
|
msgstr "Inscription"
|
||||||
|
|
||||||
|
#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja
|
||||||
|
#: matmat/templates/matmat/search_form.jinja
|
||||||
|
msgid "Search"
|
||||||
|
msgstr "Recherche"
|
||||||
|
|
||||||
#: core/templates/core/base/header.jinja
|
#: core/templates/core/base/header.jinja
|
||||||
msgid "Logout"
|
msgid "Logout"
|
||||||
msgstr "Déconnexion"
|
msgstr "Déconnexion"
|
||||||
@@ -3243,6 +3195,10 @@ msgstr "groupe d'achat"
|
|||||||
msgid "archived"
|
msgid "archived"
|
||||||
msgstr "archivé"
|
msgstr "archivé"
|
||||||
|
|
||||||
|
#: counter/models.py
|
||||||
|
msgid "updated at"
|
||||||
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: counter/models.py eboutic/models.py
|
#: counter/models.py eboutic/models.py
|
||||||
msgid "product"
|
msgid "product"
|
||||||
msgstr "produit"
|
msgstr "produit"
|
||||||
@@ -3872,14 +3828,14 @@ msgstr ""
|
|||||||
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
|
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
|
||||||
"aucune conséquence autre que le retrait de l'argent de votre compte."
|
"aucune conséquence autre que le retrait de l'argent de votre compte."
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
|
||||||
msgid "Remove this action"
|
|
||||||
msgstr "Retirer cette action"
|
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
msgid "Remove price"
|
msgid "Remove price"
|
||||||
msgstr "Retirer le prix"
|
msgstr "Retirer le prix"
|
||||||
|
|
||||||
|
#: counter/templates/counter/product_form.jinja
|
||||||
|
msgid "Remove this action"
|
||||||
|
msgstr "Retirer cette action"
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Edit product %(name)s"
|
msgid "Edit product %(name)s"
|
||||||
@@ -4249,47 +4205,6 @@ msgstr ""
|
|||||||
msgid "this page"
|
msgid "this page"
|
||||||
msgstr "cette page"
|
msgstr "cette page"
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid "Eurockéennes 2025 partnership"
|
|
||||||
msgstr "Partenariat Eurockéennes 2025"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid ""
|
|
||||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
|
||||||
"according to its own privacy policy. By clicking the accept button you "
|
|
||||||
"consent to their terms of services."
|
|
||||||
msgstr ""
|
|
||||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
|
||||||
"collecter des informations utilisateur conformément à sa propre politique de "
|
|
||||||
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
|
||||||
"leurs termes de service."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid "Privacy policy"
|
|
||||||
msgstr "Politique de confidentialité"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
#: trombi/templates/trombi/comment_moderation.jinja
|
|
||||||
msgid "Accept"
|
|
||||||
msgstr "Accepter"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid ""
|
|
||||||
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
|
||||||
msgstr ""
|
|
||||||
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
|
||||||
"Eurockéennes."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
"This partnership offers a discount of up to 33%% on tickets for Friday, "
|
|
||||||
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
|
|
||||||
msgstr ""
|
|
||||||
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
|
||||||
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
|
||||||
"du vendredi au dimanche."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid "There are no items available for sale"
|
msgid "There are no items available for sale"
|
||||||
msgstr "Aucun article n'est disponible à la vente"
|
msgstr "Aucun article n'est disponible à la vente"
|
||||||
@@ -5389,9 +5304,7 @@ msgid "Amicale/DOCEO member"
|
|||||||
msgstr "Membre de l'Amicale/DOCEO"
|
msgstr "Membre de l'Amicale/DOCEO"
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
#, fuzzy
|
msgid "UT network member"
|
||||||
#| msgid "UT network member"
|
|
||||||
msgid "UT network member (excluding UTC)"
|
|
||||||
msgstr "Cotisant du réseau UT"
|
msgstr "Cotisant du réseau UT"
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
@@ -5402,10 +5315,26 @@ msgstr "Membres du CROUS"
|
|||||||
msgid "Sbarro/ESTA member"
|
msgid "Sbarro/ESTA member"
|
||||||
msgstr "Membre de Sbarro ou de l'ESTA"
|
msgstr "Membre de Sbarro ou de l'ESTA"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "One semester Welcome Week"
|
||||||
|
msgstr "Un semestre Welcome Week"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Eurok's volunteer"
|
||||||
|
msgstr "Bénévole Eurockéennes"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Six weeks for free"
|
||||||
|
msgstr "6 semaines gratuites"
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
msgid "One day"
|
msgid "One day"
|
||||||
msgstr "Un jour"
|
msgstr "Un jour"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "GA staff member"
|
||||||
|
msgstr "Membre staff GA"
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
msgid "One semester (-20%)"
|
msgid "One semester (-20%)"
|
||||||
msgstr "Un semestre (-20%)"
|
msgstr "Un semestre (-20%)"
|
||||||
@@ -5426,6 +5355,46 @@ msgstr "Cursus branche (-20%)"
|
|||||||
msgid "Alternating cursus (-20%)"
|
msgid "Alternating cursus (-20%)"
|
||||||
msgstr "Cursus alternant (-20%)"
|
msgstr "Cursus alternant (-20%)"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "One year for free(CA offer)"
|
||||||
|
msgstr "Une année offerte (Offre CA)"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "President"
|
||||||
|
msgstr "Président⸱e"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Vice-President"
|
||||||
|
msgstr "Vice-Président⸱e"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Treasurer"
|
||||||
|
msgstr "Trésorier⸱e"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Communication supervisor"
|
||||||
|
msgstr "Responsable communication"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Secretary"
|
||||||
|
msgstr "Secrétaire"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "IT supervisor"
|
||||||
|
msgstr "Responsable info"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Board member"
|
||||||
|
msgstr "Membre du bureau"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Active member"
|
||||||
|
msgstr "Membre actif⸱ve"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Curious"
|
||||||
|
msgstr "Curieux⸱euse"
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
msgid "A new poster needs to be moderated"
|
msgid "A new poster needs to be moderated"
|
||||||
msgstr "Une nouvelle affiche a besoin d'être modérée"
|
msgstr "Une nouvelle affiche a besoin d'être modérée"
|
||||||
@@ -5512,7 +5481,7 @@ msgstr "lieu"
|
|||||||
msgid "You can not subscribe many time for the same period"
|
msgid "You can not subscribe many time for the same period"
|
||||||
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
|
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
|
||||||
|
|
||||||
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
|
#: subscription/templates/subscription/forms/create_existing_user.jinja
|
||||||
msgid ""
|
msgid ""
|
||||||
"If the subscription is done using the AE account, you must also click it on "
|
"If the subscription is done using the AE account, you must also click it on "
|
||||||
"the AE counter."
|
"the AE counter."
|
||||||
@@ -5662,6 +5631,10 @@ msgstr "fin"
|
|||||||
msgid "Moderate Trombi comments"
|
msgid "Moderate Trombi comments"
|
||||||
msgstr "Modérer les commentaires du Trombi"
|
msgstr "Modérer les commentaires du Trombi"
|
||||||
|
|
||||||
|
#: trombi/templates/trombi/comment_moderation.jinja
|
||||||
|
msgid "Accept"
|
||||||
|
msgstr "Accepter"
|
||||||
|
|
||||||
#: trombi/templates/trombi/comment_moderation.jinja
|
#: trombi/templates/trombi/comment_moderation.jinja
|
||||||
msgid "Reject"
|
msgid "Reject"
|
||||||
msgstr "Refuser"
|
msgstr "Refuser"
|
||||||
@@ -5903,3 +5876,39 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
|
|||||||
#, python-format
|
#, python-format
|
||||||
msgid "Maximum characters: %(max_length)s"
|
msgid "Maximum characters: %(max_length)s"
|
||||||
msgstr "Nombre de caractères max: %(max_length)s"
|
msgstr "Nombre de caractères max: %(max_length)s"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid "Eurockéennes 2025 partnership"
|
||||||
|
msgstr "Partenariat Eurockéennes 2025"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid ""
|
||||||
|
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
||||||
|
"according to its own privacy policy. By clicking the accept button you "
|
||||||
|
"consent to their terms of services."
|
||||||
|
msgstr ""
|
||||||
|
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||||
|
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||||
|
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||||
|
"leurs termes de service."
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid "Privacy policy"
|
||||||
|
msgstr "Politique de confidentialité"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid ""
|
||||||
|
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
||||||
|
msgstr ""
|
||||||
|
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
||||||
|
"Eurockéennes."
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"This partnership offers a discount of up to 33%% on tickets for Friday, "
|
||||||
|
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
|
||||||
|
msgstr ""
|
||||||
|
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
||||||
|
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
||||||
|
"du vendredi au dimanche."
|
||||||
|
|||||||
+1
-2
@@ -7,14 +7,13 @@ from model_bakery import baker
|
|||||||
|
|
||||||
from com.models import News
|
from com.models import News
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import SithFile, User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
class TestMatmatronch(TestCase):
|
class TestMatmatronch(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
News.objects.all().delete()
|
News.objects.all().delete()
|
||||||
SithFile.objects.all().delete()
|
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
users = [
|
users = [
|
||||||
baker.prepare(User, promo=17),
|
baker.prepare(User, promo=17),
|
||||||
|
|||||||
Generated
+64
@@ -43,6 +43,7 @@
|
|||||||
"@babel/preset-env": "^7.29.2",
|
"@babel/preset-env": "^7.29.2",
|
||||||
"@biomejs/biome": "^2.4.13",
|
"@biomejs/biome": "^2.4.13",
|
||||||
"@hey-api/openapi-ts": "^0.94.5",
|
"@hey-api/openapi-ts": "^0.94.5",
|
||||||
|
"@rollup/plugin-inject": "^5.0.5",
|
||||||
"@types/alpinejs": "^3.13.11",
|
"@types/alpinejs": "^3.13.11",
|
||||||
"@types/cytoscape-cxtmenu": "^3.4.5",
|
"@types/cytoscape-cxtmenu": "^3.4.5",
|
||||||
"@types/cytoscape-klay": "^3.1.5",
|
"@types/cytoscape-klay": "^3.1.5",
|
||||||
@@ -2382,6 +2383,52 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/plugin-inject": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.0.1",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"magic-string": "^0.30.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/pluginutils": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"picomatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sentry-internal/browser-utils": {
|
"node_modules/@sentry-internal/browser-utils": {
|
||||||
"version": "10.51.0",
|
"version": "10.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz",
|
||||||
@@ -3331,6 +3378,13 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -4021,6 +4075,16 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@babel/preset-env": "^7.29.2",
|
"@babel/preset-env": "^7.29.2",
|
||||||
"@biomejs/biome": "^2.4.13",
|
"@biomejs/biome": "^2.4.13",
|
||||||
"@hey-api/openapi-ts": "^0.94.5",
|
"@hey-api/openapi-ts": "^0.94.5",
|
||||||
|
"@rollup/plugin-inject": "^5.0.5",
|
||||||
"@types/alpinejs": "^3.13.11",
|
"@types/alpinejs": "^3.13.11",
|
||||||
"@types/cytoscape-cxtmenu": "^3.4.5",
|
"@types/cytoscape-cxtmenu": "^3.4.5",
|
||||||
"@types/cytoscape-klay": "^3.1.5",
|
"@types/cytoscape-klay": "^3.1.5",
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ documentation = "https://sith-ae.readthedocs.io/"
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
prod = [
|
prod = [
|
||||||
"psycopg[c]>=3.3.3,<4.0.0",
|
"psycopg[c]>=3.3.4,<4.0.0",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"django-debug-toolbar>=6.3.0,<7",
|
"django-debug-toolbar>=6.3.0,<7",
|
||||||
|
|||||||
+2
-14
@@ -126,8 +126,9 @@ class PicturesController(ControllerBase):
|
|||||||
if self_moderate:
|
if self_moderate:
|
||||||
new.moderator = user
|
new.moderator = user
|
||||||
try:
|
try:
|
||||||
|
new.generate_thumbnails()
|
||||||
new.full_clean()
|
new.full_clean()
|
||||||
new.generate_thumbnails(save=True)
|
new.save()
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return self.create_response({"detail": dict(e)}, status_code=409)
|
return self.create_response({"detail": dict(e)}, status_code=409)
|
||||||
|
|
||||||
@@ -176,19 +177,6 @@ class PicturesController(ControllerBase):
|
|||||||
def delete_picture(self, picture_id: int):
|
def delete_picture(self, picture_id: int):
|
||||||
self.get_object_or_exception(Picture, pk=picture_id).delete()
|
self.get_object_or_exception(Picture, pk=picture_id).delete()
|
||||||
|
|
||||||
@route.post(
|
|
||||||
"/{picture_id}/rotate/{direction}",
|
|
||||||
permissions=[IsSasAdmin],
|
|
||||||
response=PictureSchema,
|
|
||||||
url_name="rotate_picture",
|
|
||||||
)
|
|
||||||
def rotate_picture(self, picture_id: int, direction: Literal["left", "right"]):
|
|
||||||
"""Rotate the given picture and returns its edited data."""
|
|
||||||
angle = 90 if direction == "left" else 270
|
|
||||||
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
|
||||||
picture.rotate(angle)
|
|
||||||
return picture
|
|
||||||
|
|
||||||
@route.patch(
|
@route.patch(
|
||||||
"/{picture_id}/moderation",
|
"/{picture_id}/moderation",
|
||||||
permissions=[IsSasAdmin],
|
permissions=[IsSasAdmin],
|
||||||
|
|||||||
+2
-13
@@ -1,24 +1,13 @@
|
|||||||
from django.conf import settings
|
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe, foreign_key
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from sas.models import Album, Picture
|
from sas.models import Picture
|
||||||
|
|
||||||
album_recipe = Recipe(
|
|
||||||
Album,
|
|
||||||
is_in_sas=True,
|
|
||||||
is_folder=True,
|
|
||||||
is_moderated=True,
|
|
||||||
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
|
|
||||||
name=seq("Album "),
|
|
||||||
)
|
|
||||||
|
|
||||||
picture_recipe = Recipe(
|
picture_recipe = Recipe(
|
||||||
Picture,
|
Picture,
|
||||||
is_in_sas=True,
|
is_in_sas=True,
|
||||||
is_folder=False,
|
is_folder=False,
|
||||||
is_moderated=True,
|
is_moderated=True,
|
||||||
parent=foreign_key(album_recipe),
|
|
||||||
name=seq("Picture "),
|
name=seq("Picture "),
|
||||||
)
|
)
|
||||||
"""A SAS Picture fixture.
|
"""A SAS Picture fixture.
|
||||||
|
|||||||
+68
-57
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, Self
|
from typing import ClassVar, Self
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from core.models import Notification, SithFile, User
|
from core.models import Notification, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import exif_auto_rotate, resize_image
|
||||||
|
|
||||||
|
|
||||||
class SasFile(SithFile):
|
class SasFile(SithFile):
|
||||||
@@ -90,75 +92,88 @@ class Picture(SasFile):
|
|||||||
|
|
||||||
objects = SASPictureManager.from_queryset(PictureQuerySet)()
|
objects = SASPictureManager.from_queryset(PictureQuerySet)()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_vertical(self):
|
||||||
|
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
|
||||||
|
im = Image.open(BytesIO(f.read()))
|
||||||
|
(w, h) = im.size
|
||||||
|
return (w / h) < 1
|
||||||
|
|
||||||
def get_download_url(self):
|
def get_download_url(self):
|
||||||
return reverse(
|
return reverse("sas:download", kwargs={"picture_id": self.id})
|
||||||
"sas:download",
|
|
||||||
kwargs={"picture_id": self.id},
|
|
||||||
query={"date": int(self.updated_at.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_download_compressed_url(self):
|
def get_download_compressed_url(self):
|
||||||
return reverse(
|
return reverse("sas:download_compressed", kwargs={"picture_id": self.id})
|
||||||
"sas:download_compressed",
|
|
||||||
kwargs={"picture_id": self.id},
|
|
||||||
query={"date": int(self.updated_at.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_download_thumb_url(self):
|
def get_download_thumb_url(self):
|
||||||
return reverse(
|
return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
|
||||||
"sas:download_thumb",
|
|
||||||
kwargs={"picture_id": self.id},
|
|
||||||
query={"date": int(self.updated_at.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
||||||
|
|
||||||
def generate_thumbnails(
|
def generate_thumbnails(self):
|
||||||
self, *, img: Image.Image | None = None, save: bool = False
|
im = Image.open(BytesIO(self.file.read()))
|
||||||
):
|
with contextlib.suppress(Exception):
|
||||||
"""Generate the thumbnail and the compressed version of this picture.
|
im = exif_auto_rotate(im)
|
||||||
|
|
||||||
Args:
|
|
||||||
img: if given, this will be used to generate
|
|
||||||
all three images (file, compressed, thumbnail).
|
|
||||||
Else, `self.file` will be used
|
|
||||||
save: if True, save the instance in database.
|
|
||||||
"""
|
|
||||||
img = img or Image.open(self.file)
|
|
||||||
extension = self.mime_type.split("/")[-1]
|
|
||||||
previous_files = [
|
|
||||||
f.name for f in (self.file, self.thumbnail, self.compressed) if f
|
|
||||||
]
|
|
||||||
# convert the compressed image and the thumbnail into webp
|
# convert the compressed image and the thumbnail into webp
|
||||||
# The original image keeps its original type, because it's not
|
# The original image keeps its original type, because it's not
|
||||||
# meant to be shown on the website, but rather to keep the real image
|
# meant to be shown on the website, but rather to keep the real image
|
||||||
# for less frequent cases (like downloading the pictures of a user)
|
# for less frequent cases (like downloading the pictures of an user)
|
||||||
|
extension = self.mime_type.split("/")[-1]
|
||||||
# the HD version of the image doesn't need to be optimized, because :
|
# the HD version of the image doesn't need to be optimized, because :
|
||||||
# - it isn't frequently queried
|
# - it isn't frequently queried
|
||||||
# - optimizing large images takes a lot of time, which greatly hinders the UX
|
# - optimizing large images takes a lot time, which greatly hinders the UX
|
||||||
# - photographers usually already optimize their images
|
# - photographers usually already optimize their images
|
||||||
|
file = resize_image(im, max(im.size), extension, optimize=False)
|
||||||
|
thumb = resize_image(im, 200, "webp")
|
||||||
|
compressed = resize_image(im, 1200, "webp")
|
||||||
new_extension_name = str(Path(self.name).with_suffix(".webp"))
|
new_extension_name = str(Path(self.name).with_suffix(".webp"))
|
||||||
file = resize_image(img, max(img.size), extension, optimize=False)
|
self.file = file
|
||||||
self.file.save(self.name, file, save=False)
|
self.file.name = self.name
|
||||||
thumbnail = resize_image(img, 200, "webp")
|
self.thumbnail = thumb
|
||||||
self.thumbnail.save(new_extension_name, thumbnail, save=False)
|
self.thumbnail.name = new_extension_name
|
||||||
compressed = resize_image(img, 1200, "webp")
|
self.compressed = compressed
|
||||||
self.compressed.save(new_extension_name, compressed, save=save)
|
self.compressed.name = new_extension_name
|
||||||
# once the new images have been saved, delete the previous ones.
|
|
||||||
# The deletion of old files is done after, so that if anything goes
|
|
||||||
# during the whole process, no data will be lost.
|
|
||||||
for filename in previous_files:
|
|
||||||
self.file.storage.delete(filename)
|
|
||||||
|
|
||||||
def rotate(self, degree: int | float):
|
def rotate(self, degree):
|
||||||
"""Rotate this picture and update its thumbnails accordingly.
|
for attr in ["file", "compressed", "thumbnail"]:
|
||||||
|
name = self.__getattribute__(attr).name
|
||||||
|
with open(settings.MEDIA_ROOT / name, "r+b") as file:
|
||||||
|
if file:
|
||||||
|
im = Image.open(BytesIO(file.read()))
|
||||||
|
file.seek(0)
|
||||||
|
im = im.rotate(degree, expand=True)
|
||||||
|
im.save(
|
||||||
|
fp=file,
|
||||||
|
format=self.mime_type.split("/")[-1].upper(),
|
||||||
|
quality=90,
|
||||||
|
optimize=True,
|
||||||
|
progressive=True,
|
||||||
|
)
|
||||||
|
|
||||||
Args:
|
def get_next(self):
|
||||||
degree: the rotation angle, in degree, counter-clockwise
|
if self.is_moderated:
|
||||||
"""
|
pictures_qs = self.parent.children.filter(
|
||||||
img = Image.open(self.file).rotate(degree)
|
is_moderated=True,
|
||||||
self.generate_thumbnails(img=img, save=True)
|
asked_for_removal=False,
|
||||||
|
is_folder=False,
|
||||||
|
id__gt=self.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
|
||||||
|
return pictures_qs.order_by("id").first()
|
||||||
|
|
||||||
|
def get_previous(self):
|
||||||
|
if self.is_moderated:
|
||||||
|
pictures_qs = self.parent.children.filter(
|
||||||
|
is_moderated=True,
|
||||||
|
asked_for_removal=False,
|
||||||
|
is_folder=False,
|
||||||
|
id__lt=self.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
|
||||||
|
return pictures_qs.order_by("-id").first()
|
||||||
|
|
||||||
|
|
||||||
class AlbumQuerySet(models.QuerySet):
|
class AlbumQuerySet(models.QuerySet):
|
||||||
@@ -224,11 +239,7 @@ class Album(SasFile):
|
|||||||
return reverse("sas:album", kwargs={"album_id": self.id})
|
return reverse("sas:album", kwargs={"album_id": self.id})
|
||||||
|
|
||||||
def get_download_url(self):
|
def get_download_url(self):
|
||||||
return reverse(
|
return reverse("sas:album_preview", kwargs={"album_id": self.id})
|
||||||
"sas:album_preview",
|
|
||||||
kwargs={"album_id": self.id},
|
|
||||||
query={"date": int(self.updated_at.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_thumbnail(self):
|
def generate_thumbnail(self):
|
||||||
p = self.children_pictures.order_by("?").first()
|
p = self.children_pictures.order_by("?").first()
|
||||||
|
|||||||
+1
-9
@@ -70,15 +70,7 @@ class PictureFilterSchema(FilterSchema):
|
|||||||
class PictureSchema(ModelSchema):
|
class PictureSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Picture
|
model = Picture
|
||||||
fields = [
|
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"date",
|
|
||||||
"updated_at",
|
|
||||||
"size",
|
|
||||||
"is_moderated",
|
|
||||||
"asked_for_removal",
|
|
||||||
]
|
|
||||||
|
|
||||||
owner: UserProfileSchema
|
owner: UserProfileSchema
|
||||||
sas_url: str
|
sas_url: str
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { paginated } from "#core:utils/api";
|
import { paginated } from "#core:utils/api.ts";
|
||||||
import {
|
import {
|
||||||
type PictureSchema,
|
type PictureSchema,
|
||||||
type PicturesFetchPicturesData,
|
type PicturesFetchPicturesData,
|
||||||
@@ -35,23 +35,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// biome-ignore lint/style/useNamingConvention: from python api
|
// biome-ignore lint/style/useNamingConvention: from python api
|
||||||
query: { users_identified: [config.userId] },
|
query: { users_identified: [config.userId] },
|
||||||
} as PicturesFetchPicturesData);
|
} as PicturesFetchPicturesData);
|
||||||
try {
|
localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString());
|
||||||
localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString());
|
localStorage.setItem(localStorageKey, JSON.stringify(pictures));
|
||||||
localStorage.setItem(localStorageKey, JSON.stringify(pictures));
|
|
||||||
} catch {
|
|
||||||
// an exception is raised if the localstorage is entirely filled
|
|
||||||
// so just delete all cached user pictures.
|
|
||||||
// A cache hit is not worth the page breaking.
|
|
||||||
Object.keys(localStorage)
|
|
||||||
.filter(
|
|
||||||
(key) =>
|
|
||||||
key.startsWith("user") &&
|
|
||||||
(key.endsWith("Pictures") || key.endsWith("PicturesNumber")),
|
|
||||||
)
|
|
||||||
.forEach((key) => {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return pictures;
|
return pictures;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type TomSelect from "tom-select";
|
import type TomSelect from "tom-select";
|
||||||
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts";
|
||||||
import { paginated } from "#core:utils/api";
|
import { paginated } from "#core:utils/api.ts";
|
||||||
import { History } from "#core:utils/history";
|
import { History } from "#core:utils/history.ts";
|
||||||
import {
|
import {
|
||||||
type IdentifiedUserSchema,
|
type IdentifiedUserSchema,
|
||||||
type ModerationRequestSchema,
|
type ModerationRequestSchema,
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
picturesFetchPictures,
|
picturesFetchPictures,
|
||||||
picturesIdentifyUsers,
|
picturesIdentifyUsers,
|
||||||
picturesModeratePicture,
|
picturesModeratePicture,
|
||||||
picturesRotatePicture,
|
|
||||||
type UserProfileSchema,
|
type UserProfileSchema,
|
||||||
usersidentifiedDeleteRelation,
|
usersidentifiedDeleteRelation,
|
||||||
} from "#openapi";
|
} from "#openapi";
|
||||||
@@ -29,32 +28,18 @@ class PictureWithIdentifications {
|
|||||||
identificationsLoading = false;
|
identificationsLoading = false;
|
||||||
moderationLoading = false;
|
moderationLoading = false;
|
||||||
id: number;
|
id: number;
|
||||||
compressedUrl: string = "";
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
thumbUrl: string = "";
|
compressed_url: string;
|
||||||
fullSizeUrl: string = "";
|
|
||||||
moderationRequests: ModerationRequestSchema[] = null;
|
moderationRequests: ModerationRequestSchema[] = null;
|
||||||
|
|
||||||
constructor(picture: PictureSchema) {
|
constructor(picture: PictureSchema) {
|
||||||
Object.assign(this, picture);
|
Object.assign(this, picture);
|
||||||
this.compressedUrl = picture.compressed_url;
|
|
||||||
this.thumbUrl = picture.thumb_url;
|
|
||||||
this.fullSizeUrl = picture.full_size_url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromPicture(picture: PictureSchema): PictureWithIdentifications {
|
static fromPicture(picture: PictureSchema): PictureWithIdentifications {
|
||||||
return new PictureWithIdentifications(picture);
|
return new PictureWithIdentifications(picture);
|
||||||
}
|
}
|
||||||
|
|
||||||
rebuildUrls(date: Date) {
|
|
||||||
const buildUrl = (url: string) => {
|
|
||||||
const base = url.split("?", 1)[0];
|
|
||||||
return `${base}?date=${date.getTime().toString()}`;
|
|
||||||
};
|
|
||||||
this.compressedUrl = buildUrl(this.compressedUrl);
|
|
||||||
this.thumbUrl = buildUrl(this.thumbUrl);
|
|
||||||
this.fullSizeUrl = buildUrl(this.fullSizeUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If not already done, fetch the users identified on this picture and
|
* If not already done, fetch the users identified on this picture and
|
||||||
* populate the identifications field
|
* populate the identifications field
|
||||||
@@ -97,25 +82,12 @@ class PictureWithIdentifications {
|
|||||||
this.moderationLoading = false;
|
this.moderationLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rotate(direction: "left" | "right") {
|
|
||||||
this.imageLoading = true;
|
|
||||||
const res = await picturesRotatePicture({
|
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake case
|
|
||||||
path: { picture_id: this.id, direction: direction },
|
|
||||||
});
|
|
||||||
// urls returned by the api include a timestamp for cache busting
|
|
||||||
this.fullSizeUrl = res.data.full_size_url;
|
|
||||||
this.compressedUrl = res.data.compressed_url;
|
|
||||||
this.thumbUrl = res.data.thumb_url;
|
|
||||||
this.imageLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preload the photo and the identifications
|
* Preload the photo and the identifications
|
||||||
*/
|
*/
|
||||||
async preload(): Promise<void> {
|
async preload(): Promise<void> {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = this.compressedUrl;
|
img.src = this.compressed_url;
|
||||||
if (!img.complete) {
|
if (!img.complete) {
|
||||||
this.imageLoading = true;
|
this.imageLoading = true;
|
||||||
img.addEventListener("load", () => {
|
img.addEventListener("load", () => {
|
||||||
@@ -168,8 +140,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
full_size_url: "",
|
full_size_url: "",
|
||||||
owner: "",
|
owner: "",
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
date: new Date(),
|
||||||
created_at: new Date(),
|
|
||||||
identifications: [] as IdentifiedUserSchema[],
|
identifications: [] as IdentifiedUserSchema[],
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -320,8 +291,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
async submitIdentification(): Promise<void> {
|
async submitIdentification(): Promise<void> {
|
||||||
const widget: TomSelect = this.selector.widget;
|
const widget: TomSelect = this.selector.widget;
|
||||||
await picturesIdentifyUsers({
|
await picturesIdentifyUsers({
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
path: {
|
||||||
path: { picture_id: this.currentPicture.id },
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
picture_id: this.currentPicture.id,
|
||||||
|
},
|
||||||
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
|
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
|
||||||
});
|
});
|
||||||
// refresh the identified users list
|
// refresh the identified users list
|
||||||
|
|||||||
@@ -235,34 +235,37 @@
|
|||||||
|
|
||||||
>.tools {
|
>.tools {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
.btn {
|
|
||||||
background-color: $primary-neutral-light-color;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0;
|
|
||||||
color: black;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
font-size: 20px;
|
|
||||||
|
|
||||||
&:hover {
|
>div>div {
|
||||||
background-color: #aaa;
|
>a.btn {
|
||||||
|
background-color: $primary-neutral-light-color;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
color: black;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 20px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #aaa;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
a.text.danger {
|
>a.text.danger {
|
||||||
color: red;
|
color: red;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: darkred;
|
color: darkred;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
&.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_js -%}
|
{%- block additional_js -%}
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="photo" :aria-busy="currentPicture.imageLoading">
|
<div class="photo" :aria-busy="currentPicture.imageLoading">
|
||||||
<img
|
<img
|
||||||
:src="currentPicture.compressedUrl"
|
:src="currentPicture.compressed_url"
|
||||||
:alt="currentPicture.name"
|
:alt="currentPicture.name"
|
||||||
id="main-picture"
|
id="main-picture"
|
||||||
x-ref="mainPicture"
|
x-ref="mainPicture"
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<span
|
<span
|
||||||
x-text="Intl.DateTimeFormat(
|
x-text="Intl.DateTimeFormat(
|
||||||
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
|
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
|
||||||
).format(Date.parse(currentPicture.date))"
|
).format(new Date(currentPicture.date))"
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,38 +115,23 @@
|
|||||||
<h5>{% trans %}Tools{% endtrans %}</h5>
|
<h5>{% trans %}Tools{% endtrans %}</h5>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<a class="text" :href="currentPicture.fullSizeUrl">
|
<a class="text" :href="currentPicture.full_size_url">
|
||||||
{% trans %}HD version{% endtrans %}
|
{% trans %}HD version{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
<a class="text danger " :href="currentPicture.report_url">
|
<a class="text danger " :href="currentPicture.report_url">
|
||||||
{% trans %}Ask for removal{% endtrans %}
|
{% trans %}Ask for removal{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="buttons">
|
||||||
class="buttons"
|
|
||||||
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
class="btn btn-no-text"
|
class="btn btn-no-text"
|
||||||
:href="currentPicture.edit_url"
|
:href="currentPicture.edit_url"
|
||||||
:disabled="currentPicture.imageLoading"
|
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
|
||||||
>
|
>
|
||||||
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<a class="btn btn-no-text" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
|
||||||
class="btn btn-no-text"
|
<a class="btn btn-no-text" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
|
||||||
@click="currentPicture.rotate('left')"
|
|
||||||
:disabled="currentPicture.imageLoading"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-rotate-left"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-no-text"
|
|
||||||
@click="currentPicture.rotate('right')"
|
|
||||||
:disabled="currentPicture.imageLoading"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-rotate-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +146,7 @@
|
|||||||
@keyup.left.window="currentPicture = previousPicture"
|
@keyup.left.window="currentPicture = previousPicture"
|
||||||
@click="currentPicture = previousPicture"
|
@click="currentPicture = previousPicture"
|
||||||
>
|
>
|
||||||
<img :src="previousPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
|
<img :src="previousPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
|
||||||
<div class="overlay">←</div>
|
<div class="overlay">←</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -172,7 +157,7 @@
|
|||||||
@keyup.right.window="currentPicture = nextPicture"
|
@keyup.right.window="currentPicture = nextPicture"
|
||||||
@click="currentPicture = nextPicture"
|
@click="currentPicture = nextPicture"
|
||||||
>
|
>
|
||||||
<img :src="nextPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
|
<img :src="nextPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
|
||||||
<div class="overlay">→</div>
|
<div class="overlay">→</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
@@ -72,36 +67,3 @@ def test_identifications_viewable_by_user():
|
|||||||
assert list(picture.people.viewable_by(identifications[1].user)) == [
|
assert list(picture.people.viewable_by(identifications[1].user)) == [
|
||||||
identifications[1]
|
identifications[1]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@pytest.mark.parametrize("save", [True, False])
|
|
||||||
@pytest.mark.parametrize("initially_saved", [True, False])
|
|
||||||
@pytest.mark.parametrize("pass_img_kwarg", [True, False])
|
|
||||||
def test_generate_thumbnail(save, initially_saved, pass_img_kwarg):
|
|
||||||
"""Test that Picture.generate_thumbnails works properly"""
|
|
||||||
image = Image.new("RGB", (2, 1))
|
|
||||||
image.putdata([(255, 0, 0), (0, 255, 0)])
|
|
||||||
buffer = BytesIO()
|
|
||||||
image.save(buffer, format="PNG")
|
|
||||||
file = ContentFile(buffer.getvalue(), "img.png")
|
|
||||||
picture: Picture = picture_recipe.prepare(
|
|
||||||
file=file,
|
|
||||||
name=file.name,
|
|
||||||
mime_type="image/png",
|
|
||||||
_save_related=True,
|
|
||||||
)
|
|
||||||
if initially_saved:
|
|
||||||
picture.save()
|
|
||||||
picture.generate_thumbnails(img=image if pass_img_kwarg else None, save=save)
|
|
||||||
storage = picture.file.storage
|
|
||||||
for f in picture.file, picture.compressed, picture.thumbnail:
|
|
||||||
# the tested picture is alone in its album,
|
|
||||||
# so there should be a single file in each folder
|
|
||||||
assert storage.exists(f.name)
|
|
||||||
_dirs, files = storage.listdir(str(Path(f.path).parent))
|
|
||||||
assert files == [Path(f.name).name]
|
|
||||||
new_img = Image.open(picture.file)
|
|
||||||
assert new_img.get_flattened_data() == image.get_flattened_data()
|
|
||||||
assert Image.open(picture.thumbnail).size == (200, 100)
|
|
||||||
assert Image.open(picture.compressed).size == (1200, 600)
|
|
||||||
|
|||||||
+1
-70
@@ -12,23 +12,19 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from typing import Callable, Literal
|
from typing import Callable
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from PIL import Image
|
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from core.utils import RED_PIXEL_PNG
|
|
||||||
from sas.baker_recipes import picture_recipe
|
from sas.baker_recipes import picture_recipe
|
||||||
from sas.models import Album, Picture
|
from sas.models import Album, Picture
|
||||||
|
|
||||||
@@ -166,71 +162,6 @@ class TestAlbumUpload:
|
|||||||
assert not album.children.exists()
|
assert not album.children.exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestPictureRotation:
|
|
||||||
@pytest.fixture
|
|
||||||
def picture(self) -> Picture:
|
|
||||||
return picture_recipe.make(
|
|
||||||
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
|
|
||||||
file=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
|
||||||
compressed=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
|
||||||
thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"user",
|
|
||||||
[
|
|
||||||
None,
|
|
||||||
lambda: baker.make(User),
|
|
||||||
subscriber_user.make,
|
|
||||||
old_subscriber_user.make,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_permission_denied(
|
|
||||||
self, client: Client, picture: Picture, user: Callable[[], User] | None
|
|
||||||
):
|
|
||||||
if user:
|
|
||||||
client.force_login(user())
|
|
||||||
|
|
||||||
url = reverse(
|
|
||||||
"api:rotate_picture", kwargs={"picture_id": picture.id, "direction": "left"}
|
|
||||||
)
|
|
||||||
response = client.post(url)
|
|
||||||
assert response.status_code == 403 if user else 401
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"user",
|
|
||||||
[
|
|
||||||
lambda: baker.make(User, is_superuser=True),
|
|
||||||
lambda: baker.make(
|
|
||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize(("direction", "angle"), [("left", 90), ("right", 270)])
|
|
||||||
def test_rotation(
|
|
||||||
self,
|
|
||||||
client: Client,
|
|
||||||
picture: Picture,
|
|
||||||
user: Callable[[], User],
|
|
||||||
direction: Literal["left", "right"],
|
|
||||||
angle: Literal[90, 270],
|
|
||||||
):
|
|
||||||
client.force_login(user())
|
|
||||||
url = reverse(
|
|
||||||
"api:rotate_picture",
|
|
||||||
kwargs={"picture_id": picture.id, "direction": direction},
|
|
||||||
)
|
|
||||||
with (
|
|
||||||
patch.object(Image.Image, "rotate") as mocked_rotate,
|
|
||||||
patch.object(Picture, "generate_thumbnails") as mocked_thumb,
|
|
||||||
):
|
|
||||||
response = client.post(url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
mocked_rotate.assert_called_once_with(angle)
|
|
||||||
mocked_thumb.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSasModeration(TestCase):
|
class TestSasModeration(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ class PictureView(CanViewMixin, DetailView):
|
|||||||
pk_url_kwarg = "picture_id"
|
pk_url_kwarg = "picture_id"
|
||||||
template_name = "sas/picture.jinja"
|
template_name = "sas/picture.jinja"
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
if "rotate_right" in request.GET:
|
||||||
|
self.object.rotate(270)
|
||||||
|
if "rotate_left" in request.GET:
|
||||||
|
self.object.rotate(90)
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
return super().get_context_data(**kwargs) | {
|
return super().get_context_data(**kwargs) | {
|
||||||
"album": Album.objects.get(children=self.object)
|
"album": Album.objects.get(children=self.object)
|
||||||
|
|||||||
+47
-4
@@ -523,14 +523,22 @@ SITH_SUBSCRIPTIONS = {
|
|||||||
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
|
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
|
||||||
"assidu": {"name": _("Assidu member"), "price": 0, "duration": 2},
|
"assidu": {"name": _("Assidu member"), "price": 0, "duration": 2},
|
||||||
"amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2},
|
"amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2},
|
||||||
"reseau-ut (hors UTC)": {
|
"reseau-ut": {"name": _("UT network member"), "price": 0, "duration": 1},
|
||||||
"name": _("UT network member (excluding UTC)"),
|
"crous": {"name": _("CROUS member"), "price": 0, "duration": 2},
|
||||||
|
"sbarro/esta": {"name": _("Sbarro/ESTA member"), "price": 15, "duration": 2},
|
||||||
|
"un-semestre-welcome": {
|
||||||
|
"name": _("One semester Welcome Week"),
|
||||||
"price": 0,
|
"price": 0,
|
||||||
"duration": 1,
|
"duration": 1,
|
||||||
},
|
},
|
||||||
"crous": {"name": _("CROUS member"), "price": 0, "duration": 2},
|
"benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1},
|
||||||
"sbarro/esta": {"name": _("Sbarro/ESTA member"), "price": 15, "duration": 2},
|
"six-semaines-essai": {
|
||||||
|
"name": _("Six weeks for free"),
|
||||||
|
"price": 0,
|
||||||
|
"duration": 0.23,
|
||||||
|
},
|
||||||
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
|
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
|
||||||
|
"membre-staff-ga": {"name": _("GA staff member"), "price": 1, "duration": 0.076},
|
||||||
# Discount subscriptions
|
# Discount subscriptions
|
||||||
"un-semestre-reduction": {
|
"un-semestre-reduction": {
|
||||||
"name": _("One semester (-20%)"),
|
"name": _("One semester (-20%)"),
|
||||||
@@ -557,9 +565,44 @@ SITH_SUBSCRIPTIONS = {
|
|||||||
"price": 28,
|
"price": 28,
|
||||||
"duration": 6,
|
"duration": 6,
|
||||||
},
|
},
|
||||||
|
# CA special offer
|
||||||
|
"un-an-offert-CA": {
|
||||||
|
"name": _("One year for free(CA offer)"),
|
||||||
|
"price": 0,
|
||||||
|
"duration": 2,
|
||||||
|
},
|
||||||
# To be completed....
|
# To be completed....
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SITH_CLUB_ROLES_ID = {
|
||||||
|
"President": 10,
|
||||||
|
"Vice-President": 9,
|
||||||
|
"Treasurer": 7,
|
||||||
|
"Communication supervisor": 5,
|
||||||
|
"Secretary": 4,
|
||||||
|
"IT supervisor": 3,
|
||||||
|
"Board member": 2,
|
||||||
|
"Active member": 1,
|
||||||
|
"Curious": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
SITH_CLUB_ROLES = {
|
||||||
|
10: _("President"),
|
||||||
|
9: _("Vice-President"),
|
||||||
|
7: _("Treasurer"),
|
||||||
|
5: _("Communication supervisor"),
|
||||||
|
4: _("Secretary"),
|
||||||
|
3: _("IT supervisor"),
|
||||||
|
2: _("Board member"),
|
||||||
|
1: _("Active member"),
|
||||||
|
0: _("Curious"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# This corresponds to the maximum role a user can freely subscribe to
|
||||||
|
# In this case, SITH_MAXIMUM_FREE_ROLE=1 means that a user can
|
||||||
|
# set himself as "Membre actif" or "Curieux", but not higher
|
||||||
|
SITH_MAXIMUM_FREE_ROLE = 1
|
||||||
|
|
||||||
# Minutes to timeout the logged barmen
|
# Minutes to timeout the logged barmen
|
||||||
SITH_BARMAN_TIMEOUT = 30
|
SITH_BARMAN_TIMEOUT = 30
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
allowed_payment_methods = ["CARD", "CASH"]
|
allowed_payment_methods = ["CARD", "CASH"]
|
||||||
|
template_name = "subscription/forms/create_new_user.jinja"
|
||||||
|
|
||||||
__user_fields = forms.fields_for_model(
|
__user_fields = forms.fields_for_model(
|
||||||
User,
|
User,
|
||||||
@@ -120,12 +121,6 @@ class SubscriptionNewUserForm(SubscriptionForm):
|
|||||||
email=self.cleaned_data.get("email"),
|
email=self.cleaned_data.get("email"),
|
||||||
date_of_birth=self.cleaned_data.get("date_of_birth"),
|
date_of_birth=self.cleaned_data.get("date_of_birth"),
|
||||||
)
|
)
|
||||||
if self.errors:
|
|
||||||
# don't bother generating username, password and other data.
|
|
||||||
# The form validation failed anyway, so using a dummy User
|
|
||||||
# (just for Subscription.clean not to crash) is enough
|
|
||||||
self.instance.member = member
|
|
||||||
return super().clean()
|
|
||||||
if self.cleaned_data.get("subscription_type") in [
|
if self.cleaned_data.get("subscription_type") in [
|
||||||
"un-semestre",
|
"un-semestre",
|
||||||
"deux-semestres",
|
"deux-semestres",
|
||||||
@@ -158,6 +153,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
|
|||||||
class SubscriptionExistingUserForm(SubscriptionForm):
|
class SubscriptionExistingUserForm(SubscriptionForm):
|
||||||
"""Form to add a subscription to an existing user."""
|
"""Form to add a subscription to an existing user."""
|
||||||
|
|
||||||
|
template_name = "subscription/forms/create_existing_user.jinja"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
|
|
||||||
birthdate = forms.fields_for_model(
|
birthdate = forms.fields_for_model(
|
||||||
|
|||||||
@@ -1,42 +1,38 @@
|
|||||||
import { userFetchUser } from "#openapi";
|
import { userFetchUser } from "#openapi";
|
||||||
|
|
||||||
Alpine.data("existing_user_subscription_form", () => ({
|
document.addEventListener("alpine:init", () => {
|
||||||
loading: false,
|
Alpine.data("existing_user_subscription_form", () => ({
|
||||||
selectedUser: "",
|
loading: false,
|
||||||
profileFragment: "",
|
profileFragment: "" as string,
|
||||||
dateOfBirth: "",
|
|
||||||
dateOfBirthHidden: true,
|
|
||||||
|
|
||||||
init() {
|
async init() {
|
||||||
this.$watch("selectedUser", async () => {
|
const userSelect = document.getElementById("id_member") as HTMLSelectElement;
|
||||||
await this.loadProfile(Number.parseInt(this.selectedUser, 10));
|
userSelect.addEventListener("change", async () => {
|
||||||
});
|
await this.loadProfile(Number.parseInt(userSelect.value, 10));
|
||||||
|
});
|
||||||
|
await this.loadProfile(Number.parseInt(userSelect.value, 10));
|
||||||
|
},
|
||||||
|
|
||||||
this.$nextTick(() => {
|
async loadProfile(userId: number) {
|
||||||
// Force to detect the initial value
|
const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement;
|
||||||
this.selectedUser = this.$refs.userSelect.widget.getValue();
|
if (!Number.isInteger(userId)) {
|
||||||
});
|
this.profileFragment = "";
|
||||||
},
|
birthdayInput.hidden = true;
|
||||||
|
return;
|
||||||
async loadProfile(userId: number) {
|
}
|
||||||
if (!Number.isInteger(userId)) {
|
this.loading = true;
|
||||||
this.profileFragment = "";
|
const [miniProfile, userInfos] = await Promise.all([
|
||||||
this.dateOfBirth = "";
|
fetch(`/user/${userId}/mini/`),
|
||||||
this.dateOfBirthHidden = true;
|
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||||
return;
|
userFetchUser({ path: { user_id: userId } }),
|
||||||
}
|
]);
|
||||||
this.loading = true;
|
this.profileFragment = await miniProfile.text();
|
||||||
const [miniProfile, userInfos] = await Promise.all([
|
// If the user has no birthdate yet, show the form input
|
||||||
fetch(`/user/${userId}/mini/`),
|
// to fill this info.
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
// Else keep the input hidden and change its value to the user birthdate
|
||||||
userFetchUser({ path: { user_id: userId } }),
|
birthdayInput.value = userInfos.data.date_of_birth;
|
||||||
]);
|
birthdayInput.hidden = userInfos.data.date_of_birth !== null;
|
||||||
this.profileFragment = await miniProfile.text();
|
this.loading = false;
|
||||||
// If the user has no birthdate yet, show the form input
|
},
|
||||||
// to fill this info.
|
}));
|
||||||
// Else keep the input hidden and change its value to the user birthdate
|
});
|
||||||
this.dateOfBirth = userInfos.data.date_of_birth;
|
|
||||||
this.dateOfBirthHidden = userInfos.data.date_of_birth !== null;
|
|
||||||
this.loading = false;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset p:first-of-type,
|
fieldset p:first-of-type, & > p:first-of-type {
|
||||||
&>p:first-of-type {
|
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@
|
|||||||
fieldset {
|
fieldset {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
|
|
||||||
.form-group:has(input[hidden]) {
|
p:has(input[hidden]) {
|
||||||
// when the input is hidden, hide the whole label+input+help text group
|
// when the input is hidden, hide the whole label+input+help text group
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
|
||||||
|
<div x-data="existing_user_subscription_form" class="form-content existing-user">
|
||||||
|
<fieldset>
|
||||||
|
{{ errors }}
|
||||||
|
{% for field, errors in fields %}
|
||||||
|
<p{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
|
||||||
|
{{ field.label_tag }}
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<span class="helptext">{{ field.help_text }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if field.name == "payment_method" %}
|
||||||
|
<i>
|
||||||
|
{% blocktranslate %}If the subscription is done using the AE account, you must also click it on the AE counter.{% endblocktranslate %}
|
||||||
|
</i>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
<div
|
||||||
|
id="subscription-form-user-mini-profile"
|
||||||
|
x-html="profileFragment"
|
||||||
|
:aria-busy="loading"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ form.as_p }}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<form
|
<form
|
||||||
hx-post="{{ url("subscription:fragment-new-user") }}"
|
hx-post="{{ post_url }}"
|
||||||
hx-target="this"
|
hx-target="this"
|
||||||
hx-disabled-elt="find input[type='submit']"
|
hx-disabled-elt="find input[type='submit']"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<form
|
|
||||||
hx-post="{{ url("subscription:fragment-existing-user") }}"
|
|
||||||
hx-target="this"
|
|
||||||
hx-disabled-elt="find input[type='submit']"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<div x-data="existing_user_subscription_form" class="form-content existing-user">
|
|
||||||
<fieldset>
|
|
||||||
{{ form.non_field_errors() }}
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.member.label_tag() }}
|
|
||||||
{{ form.member|add_attr("x-ref=userSelect,x-model=selectedUser") }}
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.birthdate.label_tag() }}
|
|
||||||
{{ form.birthdate|add_attr(":value=dateOfBirth,:hidden=dateOfBirthHidden,:type=dateOfBirthHidden ? 'hidden' : 'date'") }}
|
|
||||||
<span class="helptext">{{ form.birthdate.help_text }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">{{ form.subscription_type.as_field_group() }}</div>
|
|
||||||
<div class="form-group">
|
|
||||||
{{ form.payment_method.as_field_group() }}
|
|
||||||
<i>
|
|
||||||
{% trans trimmed %}
|
|
||||||
If the subscription is done using the AE account,
|
|
||||||
you must also click it on the AE counter.
|
|
||||||
{% endtrans %}
|
|
||||||
</i>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">{{ form.location.as_field_group() }}</div>
|
|
||||||
</fieldset>
|
|
||||||
<div
|
|
||||||
id="subscription-form-user-mini-profile"
|
|
||||||
x-html="profileFragment"
|
|
||||||
:aria-busy="loading"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="submit" value="{% trans %}Save{% endtrans %}">
|
|
||||||
</form>
|
|
||||||
@@ -18,14 +18,22 @@
|
|||||||
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
|
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% macro form_fragment(form_object, post_url) %}
|
||||||
|
{# Include the form fragment inside a with block,
|
||||||
|
in order to inject the right form in the right place #}
|
||||||
|
{% with form=form_object, post_url=post_url %}
|
||||||
|
{% include "subscription/fragments/creation_form.jinja" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3>{% trans %}New subscription{% endtrans %}</h3>
|
<h3>{% trans %}New subscription{% endtrans %}</h3>
|
||||||
<ui-tab-group id="subscription-form">
|
<ui-tab-group id="subscription-form">
|
||||||
<ui-tab title="{% trans %}Existing member{% endtrans %}" active>
|
<ui-tab title="{% trans %}Existing member{% endtrans %}" active>
|
||||||
{{ existing_user_fragment }}
|
{{ form_fragment(existing_user_form, existing_user_post_url) }}
|
||||||
</ui-tab>
|
</ui-tab>
|
||||||
<ui-tab title="{% trans %}New member{% endtrans %}">
|
<ui-tab title="{% trans %}New member{% endtrans %}">
|
||||||
{{ new_user_fragment }}
|
{{ form_fragment(new_user_form, new_user_post_url) }}
|
||||||
</ui-tab>
|
</ui-tab>
|
||||||
</ui-tab-group>
|
</ui-tab-group>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from django.urls import reverse
|
|||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
@@ -25,7 +26,9 @@ from subscription.models import Subscription
|
|||||||
"user_factory",
|
"user_factory",
|
||||||
[old_subscriber_user.make, lambda: baker.make(User)],
|
[old_subscriber_user.make, lambda: baker.make(User)],
|
||||||
)
|
)
|
||||||
def test_form_existing_user_valid(user_factory: Callable[[], User]):
|
def test_form_existing_user_valid(
|
||||||
|
user_factory: Callable[[], User], settings: SettingsWrapper
|
||||||
|
):
|
||||||
"""Test `SubscriptionExistingUserForm`"""
|
"""Test `SubscriptionExistingUserForm`"""
|
||||||
user = user_factory()
|
user = user_factory()
|
||||||
user.date_of_birth = date(year=1967, month=3, day=14)
|
user.date_of_birth = date(year=1967, month=3, day=14)
|
||||||
@@ -45,7 +48,7 @@ def test_form_existing_user_valid(user_factory: Callable[[], User]):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_form_existing_user_with_birthdate():
|
def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
|
||||||
"""Test `SubscriptionExistingUserForm`"""
|
"""Test `SubscriptionExistingUserForm`"""
|
||||||
user = baker.make(User, date_of_birth=None)
|
user = baker.make(User, date_of_birth=None)
|
||||||
data = {
|
data = {
|
||||||
@@ -67,7 +70,7 @@ def test_form_existing_user_with_birthdate():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_form_existing_user_invalid():
|
def test_form_existing_user_invalid(settings: SettingsWrapper):
|
||||||
"""Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe."""
|
"""Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe."""
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
# make sure the current subscription will end in a long time
|
# make sure the current subscription will end in a long time
|
||||||
@@ -88,7 +91,7 @@ def test_form_existing_user_invalid():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_form_new_user():
|
def test_form_new_user(settings: SettingsWrapper):
|
||||||
data = {
|
data = {
|
||||||
"first_name": "John",
|
"first_name": "John",
|
||||||
"last_name": "Doe",
|
"last_name": "Doe",
|
||||||
@@ -118,7 +121,7 @@ def test_form_new_user():
|
|||||||
"subscription_type",
|
"subscription_type",
|
||||||
["un-semestre", "deux-semestres", "cursus-tronc-commun", "cursus-branche"],
|
["un-semestre", "deux-semestres", "cursus-tronc-commun", "cursus-branche"],
|
||||||
)
|
)
|
||||||
def test_form_set_new_user_as_student(subscription_type):
|
def test_form_set_new_user_as_student(settings: SettingsWrapper, subscription_type):
|
||||||
"""Test that new users have the student role by default."""
|
"""Test that new users have the student role by default."""
|
||||||
data = {
|
data = {
|
||||||
"first_name": "John",
|
"first_name": "John",
|
||||||
@@ -162,7 +165,7 @@ def test_page_access_with_get_data(client: Client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_submit_form_existing_user(client: Client):
|
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
|
||||||
client.force_login(
|
client.force_login(
|
||||||
baker.make(
|
baker.make(
|
||||||
User,
|
User,
|
||||||
@@ -193,7 +196,7 @@ def test_submit_form_existing_user(client: Client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_submit_form_new_user(client: Client):
|
def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
|
||||||
client.force_login(
|
client.force_login(
|
||||||
baker.make(
|
baker.make(
|
||||||
User,
|
User,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.urls import reverse
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
@@ -15,8 +15,7 @@ class TestSubscriptionPermission(TestCase):
|
|||||||
cls.user: User = subscriber_user.make()
|
cls.user: User = subscriber_user.make()
|
||||||
cls.admin = baker.make(User, is_superuser=True)
|
cls.admin = baker.make(User, is_superuser=True)
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
baker.make(Membership, user=cls.user, club=cls.club, role=7)
|
||||||
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
|
||||||
|
|
||||||
def test_give_permission(self):
|
def test_give_permission(self):
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
|||||||
+19
-14
@@ -23,7 +23,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.views.generic import CreateView, DetailView, TemplateView
|
from django.views.generic import CreateView, DetailView, TemplateView
|
||||||
from django.views.generic.edit import FormView
|
from django.views.generic.edit import FormView
|
||||||
|
|
||||||
from core.views import FragmentMixin, UseFragmentsMixin
|
|
||||||
from core.views.group import PermissionGroupsUpdateView
|
from core.views.group import PermissionGroupsUpdateView
|
||||||
from subscription.forms import (
|
from subscription.forms import (
|
||||||
SelectionDateForm,
|
SelectionDateForm,
|
||||||
@@ -33,9 +32,24 @@ from subscription.forms import (
|
|||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
class CreateSubscriptionFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
|
class NewSubscription(PermissionRequiredMixin, TemplateView):
|
||||||
|
template_name = "subscription/subscription.jinja"
|
||||||
|
permission_required = "subscription.add_subscription"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"existing_user_form": SubscriptionExistingUserForm(
|
||||||
|
initial={"member": self.request.GET.get("member")}
|
||||||
|
),
|
||||||
|
"new_user_form": SubscriptionNewUserForm(),
|
||||||
|
"existing_user_post_url": reverse("subscription:fragment-existing-user"),
|
||||||
|
"new_user_post_url": reverse("subscription:fragment-new-user"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSubscriptionFragment(PermissionRequiredMixin, CreateView):
|
||||||
|
template_name = "subscription/fragments/creation_form.jinja"
|
||||||
permission_required = "subscription.add_subscription"
|
permission_required = "subscription.add_subscription"
|
||||||
object = None
|
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
@@ -47,14 +61,14 @@ class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
|
|||||||
"""Create a subscription for a user who already exists."""
|
"""Create a subscription for a user who already exists."""
|
||||||
|
|
||||||
form_class = SubscriptionExistingUserForm
|
form_class = SubscriptionExistingUserForm
|
||||||
template_name = "subscription/fragments/creation_form_existing_user.jinja"
|
extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")}
|
||||||
|
|
||||||
|
|
||||||
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
|
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
|
||||||
"""Create a subscription for a user who doesn't exist yet."""
|
"""Create a subscription for a user who doesn't exist yet."""
|
||||||
|
|
||||||
form_class = SubscriptionNewUserForm
|
form_class = SubscriptionNewUserForm
|
||||||
template_name = "subscription/fragments/creation_form_new_user.jinja"
|
extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")}
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
res = super().form_valid(form)
|
res = super().form_valid(form)
|
||||||
@@ -69,15 +83,6 @@ class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
class NewSubscription(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
|
|
||||||
template_name = "subscription/subscription.jinja"
|
|
||||||
permission_required = "subscription.add_subscription"
|
|
||||||
fragments = {
|
|
||||||
"new_user_fragment": CreateSubscriptionNewUserFragment,
|
|
||||||
"existing_user_fragment": CreateSubscriptionExistingUserFragment,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView):
|
class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView):
|
||||||
template_name = "subscription/fragments/creation_success.jinja"
|
template_name = "subscription/fragments/creation_success.jinja"
|
||||||
permission_required = "subscription.add_subscription"
|
permission_required = "subscription.add_subscription"
|
||||||
|
|||||||
+5
-6
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -151,12 +152,10 @@ class TrombiUser(models.Model):
|
|||||||
|
|
||||||
def make_memberships(self):
|
def make_memberships(self):
|
||||||
self.memberships.all().delete()
|
self.memberships.all().delete()
|
||||||
for m in (
|
for m in self.user.memberships.filter(
|
||||||
self.user.memberships.filter(role__is_board=True)
|
role__gt=settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
.select_related("role")
|
).order_by("end_date"):
|
||||||
.order_by("end_date")
|
role = str(settings.SITH_CLUB_ROLES[m.role])
|
||||||
):
|
|
||||||
role = m.role.name
|
|
||||||
if m.description:
|
if m.description:
|
||||||
role += " (%s)" % m.description
|
role += " (%s)" % m.description
|
||||||
end_date = get_semester_code(m.end_date) if m.end_date else ""
|
end_date = get_semester_code(m.end_date) if m.end_date else ""
|
||||||
|
|||||||
+10
-3
@@ -1,4 +1,5 @@
|
|||||||
import { parse, resolve } from "node:path";
|
import { parse, resolve } from "node:path";
|
||||||
|
import inject from "@rollup/plugin-inject";
|
||||||
import { glob } from "glob";
|
import { glob } from "glob";
|
||||||
import { visualizer } from "rollup-plugin-visualizer";
|
import { visualizer } from "rollup-plugin-visualizer";
|
||||||
import {
|
import {
|
||||||
@@ -80,8 +81,14 @@ export default defineConfig((config: UserConfig) => {
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: getAliases(),
|
alias: getAliases(),
|
||||||
},
|
},
|
||||||
// biome-ignore lint/style/useNamingConvention: that's how it's called
|
|
||||||
inject: { Alpine: "alpinejs", htmx: "htmx.org" },
|
plugins: [
|
||||||
plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption],
|
inject({
|
||||||
|
// biome-ignore lint/style/useNamingConvention: that's how it's called
|
||||||
|
Alpine: "alpinejs",
|
||||||
|
htmx: "htmx.org",
|
||||||
|
}),
|
||||||
|
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
|
||||||
|
],
|
||||||
} satisfies UserConfig;
|
} satisfies UserConfig;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user