Compare commits

..

4 Commits

Author SHA1 Message Date
imperosol
e5b5568d51 WIP 2025-08-30 19:31:34 +02:00
imperosol
e1331c7cb6 get_list_exact_or_404 util function 2025-08-30 19:31:34 +02:00
imperosol
6f7167c5b4 fix some tests 2025-08-30 19:31:34 +02:00
imperosol
078ee3a016 Migrate albums and pictures to their own tables 2025-08-30 19:31:34 +02:00
208 changed files with 6097 additions and 6790 deletions

View File

@@ -6,7 +6,7 @@ addAssignees: author
# A list of team reviewers to be added to pull requests (GitHub team slug)
reviewers:
- ae-utbm/developpeurs
- ae-utbm/sith-3-developers
# Number of reviewers has no impact on GitHub teams
# Set 0 to add all the reviewers (default: 0)

View File

@@ -4,28 +4,11 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
multi-ecosystem-groups:
common:
directory: "/"
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
target-branch: "taiste"
commit-message:
prefix: "[UPDATE] "
updates:
- package-ecosystem: "uv"
patterns: ["*"]
multi-ecosystem-group: "common"
- package-ecosystem: "npm"
patterns: ["*"]
multi-ecosystem-group: "common"
groups:
# npm supports production and development groups, but not uv
# cf. https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#dependency-type-groups
main-deps:
dependency-type: "production"
dev-deps:
dependency-type: "development"

View File

@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.4
rev: v0.11.13
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -14,7 +14,7 @@ repos:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.10
rev: 3.0.7
hooks:
- id: djhtml
name: format templates

View File

@@ -6,8 +6,6 @@ from api.models import ApiClient, ApiKey
class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
param_name = "X-APIKey"
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:

View File

@@ -1,48 +0,0 @@
import pytest
from django.test import Client
from django.urls import path
from model_bakery import baker
from ninja import NinjaAPI
from ninja.security import SessionAuth
from api.auth import ApiKeyAuth
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
api = NinjaAPI()
@api.post("", auth=[ApiKeyAuth(), SessionAuth()])
def post_method(*args, **kwargs) -> None:
"""Dummy POST route authenticated by either api key or session cookie."""
pass
urlpatterns = [path("", api.urls)]
@pytest.mark.django_db
@pytest.mark.urls(__name__)
@pytest.mark.parametrize("user_logged_in", [False, True])
def test_csrf_token(user_logged_in):
"""Test that CSRF check happens only when no api key is used."""
client = Client(enforce_csrf_checks=True)
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
if user_logged_in:
client.force_login(api_client.owner)
response = client.post("")
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"
# if using a valid API key, CSRF check should not occur
response = client.post("", headers={"X-APIKey": key})
assert response.status_code == 200
# if using a wrong API key, ApiKeyAuth should fail,
# leading to a fallback into SessionAuth and a CSRF check
response = client.post("", headers={"X-APIKey": generate_key()[0]})
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"

View File

@@ -1,4 +1,3 @@
from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI
api = NinjaExtraAPI(
@@ -6,6 +5,6 @@ api = NinjaExtraAPI(
description="Portail Interactif de Communication avec les Outils Numériques",
version="0.2.0",
urls_namespace="api",
auth=[SessionAuth()],
csrf=True,
)
api.auto_discover_controllers()

View File

@@ -1,5 +1,7 @@
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Prefetch
from ninja import Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
@@ -8,7 +10,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm
from club.models import Club, Membership
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
from club.schemas import ClubSchema, SimpleClubSchema
@api_controller("/club")
@@ -16,18 +18,18 @@ class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleClubSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
url_name="search_club",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]):
return filters.filter(Club.objects.all())
def search_club(self, search: Annotated[str, MinLen(1)]):
return Club.objects.filter(name__icontains=search).values()
@route.get(
"/{int:club_id}",
response=ClubSchema,
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[HasPerm("club.view_club")],
url_name="fetch_club",
)

View File

@@ -26,16 +26,12 @@ from django import forms
from django.conf import settings
from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Lower
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
from counter.models import Counter, Selling
@@ -192,81 +188,70 @@ class SellingsForm(forms.Form):
)
class ClubOldMemberForm(forms.Form):
members_old = forms.ModelMultipleChoiceField(
Membership.objects.none(),
label=_("Mark as old"),
widget=forms.CheckboxSelectMultiple,
required=False,
)
def __init__(self, *args, user: User, club: Club, **kwargs):
super().__init__(*args, **kwargs)
self.fields["members_old"].queryset = (
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
class ClubMemberForm(forms.ModelForm):
"""Form to add a member to the club, as a board member."""
class ClubMemberForm(forms.Form):
"""Form handling the members of a club."""
error_css_class = "error"
required_css_class = "required"
class Meta:
model = Membership
fields = ["role", "description"]
users = forms.ModelMultipleChoiceField(
label=_("Users to add"),
help_text=_("Search users to add (one or more)."),
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
)
def __init__(self, *args, club: Club, request_user: User, **kwargs):
self.club = club
self.request_user = request_user
def __init__(self, *args, **kwargs):
self.club = kwargs.pop("club")
self.request_user = kwargs.pop("request_user")
self.club_members = kwargs.pop("club_members", None)
if not self.club_members:
self.club_members = self.club.members.ongoing().order_by("-role").all()
self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs)
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
# Using a ModelForm binds too much the form with the model and we don't want that
# We want the view to process the model creation since they are multiple users
# We also want the form to handle bulk deletion
self.fields.update(
forms.fields_for_model(
Membership,
fields=("role", "start_date", "description"),
widgets={"start_date": SelectDate},
)
)
# Role is required only if users is specified
self.fields["role"].required = False
# Start date and description are never really required
self.fields["start_date"].required = False
self.fields["description"].required = False
self.fields["users_old"] = forms.ModelMultipleChoiceField(
User.objects.filter(
id__in=[
ms.user.id
for ms in self.club_members
if ms.can_be_edited_by(self.request_user)
]
self.instance.club = club
).all(),
label=_("Mark as old"),
required=False,
widget=forms.CheckboxSelectMultiple,
)
if not self.request_user.is_root:
self.fields.pop("start_date")
@property
def max_available_role(self):
"""The greatest role that will be obtainable with this form."""
# this is unreachable, because it will be overridden by subclasses
return -1 # pragma: no cover
class ClubAddMemberForm(ClubMemberForm):
"""Form to add a member to the club, as a board member."""
class Meta(ClubMemberForm.Meta):
fields = ["user", *ClubMemberForm.Meta.fields]
widgets = {"user": AutoCompleteSelectUser}
@cached_property
def max_available_role(self):
"""The greatest role that will be obtainable with this form.
Admins and the club president can attribute any role.
Board members can attribute roles lower than their own.
Other users cannot attribute roles with this form
"""
if self.request_user.has_perm("club.add_membership"):
return settings.SITH_CLUB_ROLES_ID["President"]
membership = self.request_user_membership
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return -1
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return membership.role
return membership.role - 1
def clean_user(self):
"""Check that the user is not trying to add a user already in the club.
def clean_users(self):
"""Check that the user is not trying to add an user already in the club.
Also check that the user is valid and has a valid subscription.
"""
user = self.cleaned_data["user"]
cleaned_data = super().clean()
users = []
for user in cleaned_data["users"]:
if not user.is_subscribed:
raise forms.ValidationError(
_("User must be subscriber to take part to a club"), code="invalid"
@@ -275,30 +260,33 @@ class ClubAddMemberForm(ClubMemberForm):
raise forms.ValidationError(
_("You can not add the same user twice"), code="invalid"
)
return user
class JoinClubForm(ClubMemberForm):
"""Form to join a club."""
def __init__(self, *args, club: Club, request_user: 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
@cached_property
def max_available_role(self):
return settings.SITH_MAXIMUM_FREE_ROLE
users.append(user)
return users
def clean(self):
"""Check that the user is subscribed and isn't already in the club."""
if not self.request_user.is_subscribed:
raise forms.ValidationError(
_("You must be subscribed to join a club"), code="invalid"
)
if self.club.get_membership_for(self.request_user):
raise forms.ValidationError(
_("You are already a member of this club"), code="invalid"
)
return super().clean()
"""Check user rights for adding an user."""
cleaned_data = super().clean()
if "start_date" in cleaned_data and not cleaned_data["start_date"]:
# Drop start_date if allowed to edition but not specified
cleaned_data.pop("start_date")
if not cleaned_data.get("users"):
# No user to add equals no check needed
return cleaned_data
if cleaned_data.get("role", "") == "":
# Role is required if users exists
self.add_error("role", _("You should specify a role"))
return cleaned_data
request_user = self.request_user
membership = self.request_user_membership
if not (
cleaned_data["role"] <= settings.SITH_MAXIMUM_FREE_ROLE
or (membership is not None and membership.role >= cleaned_data["role"])
or request_user.is_board_member
or request_user.is_root
):
raise forms.ValidationError(_("You do not have the permission to do that"))
return cleaned_data

View File

@@ -34,10 +34,12 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
clubs = list(Club.objects.all())
for club in clubs:
club.board_group = meta_groups.get_or_create(
name=f"{club.unix_name}-bureau", defaults={"is_meta": True}
name=club.unix_name + settings.SITH_BOARD_SUFFIX,
defaults={"is_meta": True},
)[0]
club.members_group = meta_groups.get_or_create(
name=f"{club.unix_name}-membres", defaults={"is_meta": True}
name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
defaults={"is_meta": True},
)[0]
club.save()
club.refresh_from_db()

View File

@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name="membership",
constraint=models.CheckConstraint(
condition=models.Q(("end_date__gte", models.F("start_date"))),
check=models.Q(("end_date__gte", models.F("start_date"))),
name="end_after_start",
),
),

View File

@@ -30,8 +30,7 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
@@ -43,13 +42,6 @@ from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User
class ClubQuerySet(models.QuerySet):
def having_board_member(self, user: User) -> Self:
"""Filter all club in which the given user is a board member."""
active_memberships = user.memberships.board().ongoing()
return self.filter(Exists(active_memberships.filter(club=OuterRef("pk"))))
class Club(models.Model):
"""The Club class, made as a tree to allow nice tidy organization."""
@@ -99,8 +91,6 @@ class Club(models.Model):
Group, related_name="club_board", on_delete=models.PROTECT
)
objects = ClubQuerySet.as_manager()
class Meta:
ordering = ["name"]
@@ -210,6 +200,10 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
def can_be_viewed_by(self, user: User) -> bool:
"""Method to see if that object can be seen by the given user."""
return user.was_subscribed
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user.
@@ -249,44 +243,6 @@ class MembershipQuerySet(models.QuerySet):
"""
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit.
Users with the `club.change_membership` permission can edit all Membership.
The other users can edit :
- their own membership
- if they are board members, ongoing memberships with a role lower than their own
For example, let's suppose the following users :
- A : board member
- B : board member
- C : simple member
- D : curious
- E : old member
A will be able to edit the memberships of A, C and D ;
C and D will be able to edit only their own membership ;
nobody will be able to edit E's membership.
"""
if user.has_perm("club.change_membership"):
return self.all()
return self.filter(
Q(user=user)
| Exists(
Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
user=user,
end_date=None,
club=OuterRef("club"),
)
),
end_date=None,
)
def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership.
@@ -363,12 +319,16 @@ class Membership(models.Model):
User,
verbose_name=_("user"),
related_name="memberships",
null=False,
blank=False,
on_delete=models.CASCADE,
)
club = models.ForeignKey(
Club,
verbose_name=_("club"),
related_name="members",
null=False,
blank=False,
on_delete=models.CASCADE,
)
start_date = models.DateField(_("start date"), default=timezone.now)
@@ -387,7 +347,7 @@ class Membership(models.Model):
class Meta:
constraints = [
models.CheckConstraint(
condition=Q(end_date__gte=F("start_date")), name="end_after_start"
check=Q(end_date__gte=F("start_date")), name="end_after_start"
),
]

View File

@@ -1,26 +1,9 @@
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q
from ninja import Field, FilterSchema, ModelSchema
from ninja import ModelSchema
from club.models import Club, Membership
from core.schemas import SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
is_active: bool | None = None
parent_id: int | None = None
parent_name: str | None = Field(None, q="parent__name__icontains")
exclude_ids: set[int] | None = None
def filter_exclude_ids(self, value: set[int] | None):
if value is None:
return Q()
return ~Q(id__in=value)
class SimpleClubSchema(ModelSchema):
class Meta:
model = Club

View File

@@ -1,24 +0,0 @@
#club_members_table {
tbody label {
margin: 0;
padding: 0;
}
}
#add_club_members_form {
fieldset {
display: flex;
flex-direction: row;
column-gap: 2em;
row-gap: 1em;
flex-wrap: wrap;
@media (max-width: 1100px) {
justify-content: space-evenly;
}
.errorlist {
max-width: 300px;
}
}
}

View File

@@ -1,26 +1,6 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link %}
{% block title -%}
{{ club.name }}
{%- endblock %}
{% block description -%}
{{ club.short_description }}
{%- endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(club.get_absolute_url()) }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ club.name }}" />
<meta property="og:description" content="{{ club.short_description }}" />
{% if club.logo %}
<meta property="og:image" content="{{ request.build_absolute_uri(club.logo.url) }}" />
{% else %}
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endif %}
{% endblock %}
{% block content %}
<div id="club_detail">
{% if club.logo %}
@@ -29,7 +9,7 @@
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{{ club.name }}</h3>
<h3>{% trans %}Club{% endtrans %}</h3>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %}
{% block title -%}
{% block title %}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% endblock %}
{% macro display_club(club) -%}
@@ -25,7 +21,7 @@
{%- if club.children.all()|length != 0 %}
<ul>
{%- for c in club.children.order_by('name').prefetch_related("children") %}
{%- for c in club.children.order_by('name') %}
{{ display_club(c) }}
{%- endfor %}
</ul>
@@ -40,8 +36,8 @@
{% if club_list %}
<h3>{% trans %}Club list{% endtrans %}</h3>
<ul>
{%- for club in club_list %}
{{ display_club(club) }}
{%- for c in club_list.all().order_by('name') if c.parent is none %}
{{ display_club(c) }}
{%- endfor %}
</ul>
{% else %}

View File

@@ -1,33 +1,15 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
{% endblock %}
{% block content %}
{% block notifications %}
{# Notifications are moved a little bit below #}
{% endblock %}
<h2>{% trans %}Club members{% endtrans %}</h2>
{% if add_member_fragment %}
<br />
{{ add_member_fragment }}
<br />
{% endif %}
{% include "core/base/notifications.jinja" %}
{% if members %}
<form action="{{ url('club:club_members', club_id=club.id) }}" id="members_old" method="post">
<form action="{{ url('club:club_members', club_id=club.id) }}" id="users_old" method="post">
{% csrf_token %}
{% if can_end_membership %}
{{ select_all_checkbox("members_old") }}
<br />
{% set users_old = dict(form.users_old | groupby("choice_label")) %}
{% if users_old %}
{{ select_all_checkbox("users_old") }}
<p></p>
{% endif %}
<table id="club_members_table">
<thead>
@@ -36,7 +18,7 @@
<td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td>
{% if can_end_membership %}
{% if users_old %}
<td>{% trans %}Mark as old{% endtrans %}</td>
{% endif %}
</tr>
@@ -48,24 +30,20 @@
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{%- if can_end_membership -%}
{% if users_old %}
<td>
{%- if m.is_editable -%}
<label for="id_members_old_{{ loop.index }}"></label>
<input
type="checkbox"
name="members_old"
value="{{ m.id }}"
id="id_members_old_{{ loop.index }}"
>
{%- endif -%}
{% set user_old = users_old[m.user.get_display_name()] %}
{% if user_old %}
{{ user_old[0].tag() }}
{% endif %}
</td>
{%- endif -%}
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% if can_end_membership %}
{{ form.users_old.errors }}
{% if users_old %}
<p></p>
<input type="submit" name="submit" value="{% trans %}Mark as old{% endtrans %}">
{% endif %}
@@ -73,4 +51,32 @@
{% else %}
<p>{% trans %}There are no members in this club.{% endtrans %}</p>
{% endif %}
<form action="{{ url('club:club_members', club_id=club.id) }}" id="add_users" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
<p>
{{ form.users.errors }}
<label for="{{ form.users.id_for_label }}">{{ form.users.label }} :</label>
{{ form.users }}
<span class="helptext">{{ form.users.help_text }}</span>
</p>
<p>
{{ form.role.errors }}
<label for="{{ form.role.id_for_label }}">{{ form.role.label }} :</label>
{{ form.role }}
</p>
{% if form.start_date %}
<p>
{{ form.start_date.errors }}
<label for="{{ form.start_date.id_for_label }}">{{ form.start_date.label }} :</label>
{{ form.start_date }}
</p>
{% endif %}
<p>
{{ form.description.errors }}
<label for="{{ form.description.id_for_label }}">{{ form.description.label }} :</label>
{{ form.description }}
</p>
<p><input type="submit" value="{% trans %}Add{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -5,22 +5,20 @@
<h2>{% trans %}Club old members{% endtrans %}</h2>
<table>
<thead>
<tr>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Role{% endtrans %}</td>
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for member in old_members %}
{% for m in club.members.exclude(end_date=None).order_by('-role', 'description', '-end_date').all() %}
<tr>
<td>{{ user_profile_link(member.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
<td>{{ member.description }}</td>
<td>{{ member.start_date }}</td>
<td>{{ member.end_date }}</td>
<td>{{ user_profile_link(m.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -6,11 +6,11 @@ because it works with a somewhat dynamic form,
but was written before Alpine was introduced in the project.
TODO : rewrite the pagination used in this template an Alpine one
#}
{% macro paginate(page_obj, paginator) %}
{% set js = "formPagination(this)" %}
{% macro paginate(page_obj, paginator, js_action) %}
{% set js = js_action|default('') %}
{% if page_obj.has_previous() or page_obj.has_next() %}
{% if page_obj.has_previous() %}
<a type="submit" onclick="{{ js }}" href="?page={{ page_obj.previous_page_number() }}">{% trans %}Previous{% endtrans %}</a>
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ page_obj.previous_page_number() }}">{% trans %}Previous{% endtrans %}</a>
{% else %}
<span class="disabled">{% trans %}Previous{% endtrans %}</span>
{% endif %}
@@ -18,11 +18,11 @@ TODO : rewrite the pagination used in this template an Alpine one
{% if page_obj.number == i %}
<span class="active">{{ i }} <span class="sr-only">({% trans %}current{% endtrans %})</span></span>
{% else %}
<a type="submit" onclick="{{ js }}" href="?page={{ i }}">{{ i }}</a>
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ i }}">{{ i }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next() %}
<a type="submit" onclick="{{ js }}" href="?page={{ page_obj.next_page_number() }}">{% trans %}Next{% endtrans %}</a>
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ page_obj.next_page_number() }}">{% trans %}Next{% endtrans %}</a>
{% else %}
<span class="disabled">{% trans %}Next{% endtrans %}</span>
{% endif %}
@@ -81,18 +81,14 @@ TODO : rewrite the pagination used in this template an Alpine one
{% endfor %}
</tbody>
</table>
{{ paginate(paginated_result, paginator) }}
{% endblock %}
{% block script %}
<script type="text/javascript">
function formPagination(link){
const form = document.getElementById("form")
form.action = link.href;
$("form").attr("action", link.href);
link.href = "javascript:void(0)"; // block link action
form.submit();
$("form").submit();
}
</script>
{{ paginate(paginated_result, paginator, "formPagination(this)") }}
{% endblock %}

View File

@@ -1,46 +0,0 @@
<section id="member-fragment-container">
{% if form.user %}
<h4>{% trans %}Add a new member{% endtrans %}</h4>
{% else %}
<h4>{% trans %}Join club{% endtrans %}</h4>
{% endif %}
<form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
hx-target="#member-fragment-container"
id="add_club_members_form"
>
{% csrf_token %}
{{ form.non_field_errors() }}
<fieldset>
{% if form.user %}
<div>
{{ form.user.label_tag() }}
<span class="helptext">{{ form.user.help_text }}</span>
{{ form.user }}
{{ form.user.errors }}
</div>
{% endif %}
<div>
{{ form.role.label_tag() }}
{{ form.role }}
{{ form.role.errors }}
</div>
<div>
{{ form.description.label_tag() }}
{{ form.description }}
{{ form.description.errors }}
</div>
</fieldset>
<button type="submit" class="btn btn-blue">
<i class="fa fa-user-plus"></i>
{%- if form.user -%}
{% trans %}Add{% endtrans %}
{%- else -%}
{% trans %}Join{% endtrans %}
{%- endif -%}
</button>
</form>
</section>

View File

@@ -43,9 +43,6 @@ class TestClub(TestCase):
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club)
cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id}
)
cls.members_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
a_month_ago = now() - timedelta(days=30)
yesterday = now() - timedelta(days=1)

View File

@@ -1,27 +0,0 @@
from datetime import timedelta
import pytest
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
def test_club_queryset_having_board_member():
clubs = baker.make(Club, _quantity=5)
user = subscriber_user.make()
membership_recipe = Recipe(
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(
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
)
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id}

View File

@@ -1,8 +1,7 @@
from datetime import date, timedelta
import pytest
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
@@ -10,54 +9,6 @@ from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.models import Group, Page, User
class TestClubSearch(TestCase):
@classmethod
def setUpTestData(cls):
cls.url = reverse("api:search_club")
cls.user = baker.make(
User, user_permissions=[Permission.objects.get(codename="access_lookup")]
)
# delete existing clubs to avoid side effect
groups = list(
Group.objects.exclude(club=None, club_board=None).values_list(
"id", flat=True
)
)
Page.objects.exclude(club=None).delete()
Club.objects.all().delete()
Group.objects.filter(id__in=groups).delete()
cls.clubs = baker.make(
Club,
_quantity=5,
name=iter(["AE", "ae 1", "Troll", "Dev AE", "pdf"]),
is_active=True,
)
def test_inactive_club(self):
self.client.force_login(self.user)
inactive_ids = {self.clubs[0].id, self.clubs[2].id}
Club.objects.filter(id__in=inactive_ids).update(is_active=False)
response = self.client.get(self.url, {"is_active": False})
assert response.status_code == 200
assert {d["id"] for d in response.json()["results"]} == inactive_ids
def test_excluded_id(self):
self.client.force_login(self.user)
response = self.client.get(self.url, {"exclude_ids": [self.clubs[1].id]})
assert response.status_code == 200
ids = {d["id"] for d in response.json()["results"]}
assert ids == {c.id for c in [self.clubs[0], *self.clubs[2:]]}
def test_club_search(self):
self.client.force_login(self.user)
response = self.client.get(self.url, {"search": "AE"})
assert response.status_code == 200
ids = {d["id"] for d in response.json()["results"]}
assert ids == {c.id for c in [self.clubs[0], self.clubs[1], self.clubs[3]]}
@pytest.mark.django_db

View File

@@ -1,20 +1,13 @@
from collections.abc import Callable
from datetime import timedelta
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate, localtime, now
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, Membership
from club.forms import ClubMemberForm
from club.models import Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
@@ -144,38 +137,6 @@ class TestMembershipQuerySet(TestClub):
assert set(user.groups.all()).isdisjoint(club_groups)
class TestMembershipEditableBy(TestCase):
@classmethod
def setUpTestData(cls):
Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
cls.memberships = [
*baker.make(
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):
perm = Permission.objects.get(codename="change_membership")
user = baker.make(User, user_permissions=[perm])
qs = Membership.objects.editable_by(user).values_list("id", flat=True)
assert set(qs) == set(Membership.objects.values_list("id", flat=True))
def test_simple_subscriber_user(self):
user = subscriber_user.make()
assert not Membership.objects.editable_by(user).exists()
def test_board_member(self):
# a board member can end lower memberships and its own one
user = self.memberships[2].user
qs = Membership.objects.editable_by(user).values_list("id", flat=True)
expected = {self.memberships[2].id, self.memberships[3].id}
assert set(qs) == expected
class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: int):
"""Assert that the given membership is active and started today."""
@@ -190,7 +151,7 @@ class TestMembership(TestClub):
def assert_membership_ended_today(self, user: User):
"""Assert that the given user have a membership which ended today."""
today = localdate()
today = localtime(now()).date()
assert user.memberships.filter(club=self.club, end_date=today).exists()
assert self.club.get_membership_for(user) is None
@@ -199,9 +160,7 @@ class TestMembership(TestClub):
cannot see the page.
"""
response = self.client.post(self.members_url)
assertRedirects(
response, reverse("core:login", query={"next": self.members_url})
)
assert response.status_code == 403
self.client.force_login(self.public)
response = self.client.post(self.members_url)
@@ -212,9 +171,7 @@ class TestMembership(TestClub):
information are displayed.
"""
self.client.force_login(self.simple_board_member)
response = self.client.get(
reverse("club:club_members", kwargs={"club_id": self.club.id})
)
response = self.client.get(self.members_url)
assert response.status_code == 200
soup = BeautifulSoup(response.text, "lxml")
table = soup.find("table", id="club_members_table")
@@ -240,45 +197,59 @@ class TestMembership(TestClub):
assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date)
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
# 3 is the role of simple_board_member
if membership.role <= 3: # 3 is the role of simple_board_member
form_input = cols[4].find("input")
expected_attrs = {
"type": "checkbox",
"name": "members_old",
"value": str(membership.id),
"name": "users_old",
"value": str(user.id),
}
assert form_input.attrs.items() >= expected_attrs.items()
else:
assert cols[4].find_all() == []
def test_root_add_one_club_member(self):
"""Test that root users can add members to clubs"""
"""Test that root users can add members to clubs, one at a time."""
self.client.force_login(self.root)
response = self.client.post(
self.new_members_url, {"user": self.subscriber.id, "role": 3}
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
self.members_url,
{"users": [self.subscriber.id], "role": 3},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
def test_root_add_multiple_club_member(self):
"""Test that root users can add multiple members at once to clubs."""
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{
"users": (self.subscriber.id, self.krophil.id),
"role": 3,
},
)
self.assertRedirects(response, self.members_url)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
self.assert_membership_started_today(self.krophil, role=3)
def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed
cannot be members of clubs.
"""
for user in self.public, self.old_subscriber:
form = ClubAddMemberForm(
data={"user": user.id, "role": 1},
form = ClubMemberForm(
data={"users": [user.id], "role": 1},
request_user=self.root,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"user": ["L'utilisateur doit être cotisant pour faire partie d'un club"]
"users": [
"L'utilisateur doit être cotisant pour faire partie d'un club"
]
}
def test_add_members_already_members(self):
@@ -310,16 +281,16 @@ class TestMembership(TestClub):
nb_memberships = self.club.members.count()
max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubAddMemberForm(
data={"user": members, "role": 1},
form = ClubMemberForm(
data={"users": members, "role": 1},
request_user=self.root,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"user": [
"users": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
f"{max_id + 1} n\u2019en fait pas partie."
]
}
self.club.refresh_from_db()
@@ -332,12 +303,10 @@ class TestMembership(TestClub):
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.members_url,
{"users": self.subscriber.id, "role": 9},
)
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1
@@ -348,8 +317,8 @@ class TestMembership(TestClub):
"""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},
form = ClubMemberForm(
data={"users": [self.subscriber.id], "role": 10},
request_user=self.simple_board_member,
club=self.club,
)
@@ -357,7 +326,7 @@ class TestMembership(TestClub):
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
"__all__": ["Vous n'avez pas la permission de faire cela"]
}
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
@@ -365,53 +334,23 @@ class TestMembership(TestClub):
def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails."""
form = ClubAddMemberForm(
data={"user": self.subscriber.id}, request_user=self.root, club=self.club
)
assert not form.is_valid()
assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self):
form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": 3},
request_user=self.root,
self.client.force_login(self.root)
form = ClubMemberForm(
data={"users": [self.subscriber.id]},
request_user=self.simple_board_member,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"user": ["Vous ne pouvez pas ajouter deux fois le même utilisateur"]
}
def test_add_other_member_forbidden(self):
non_member = subscriber_user.make()
simple_member = baker.make(Membership, club=self.club, role=1).user
for user in non_member, simple_member:
form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": 1},
request_user=user,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
}
def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members"""
user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=1)
self.client.force_login(user)
res = self.client.get(self.members_url)
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
assert not soup.find(id="add_club_members_form")
assert form.errors == {"role": ["Vous devez choisir un rôle"]}
def test_end_membership_self(self):
"""Test that a member can end its own membership."""
self.client.force_login(self.simple_board_member)
membership = self.club.members.get(end_date=None, user=self.simple_board_member)
self.client.post(self.members_url, {"members_old": [membership.id]})
self.client.post(
self.members_url,
{"users_old": self.simple_board_member.id},
)
self.simple_board_member.refresh_from_db()
self.assert_membership_ended_today(self.simple_board_member)
@@ -419,13 +358,15 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships
of users with lower roles.
"""
# reminder : simple_board_member has role 3
# remainder : simple_board_member has role 3, president has role 10, richard has role 1
self.client.force_login(self.simple_board_member)
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
response = self.client.post(self.members_url, {"members_old": [membership.id]})
response = self.client.post(
self.members_url,
{"users_old": self.richard.id},
)
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
self.assert_membership_ended_today(membership.user)
self.assert_membership_ended_today(self.richard)
def test_end_membership_higher_role(self):
"""Test that board members of the club cannot end memberships
@@ -433,30 +374,46 @@ class TestMembership(TestClub):
"""
membership = self.president.memberships.filter(club=self.club).first()
self.client.force_login(self.simple_board_member)
self.client.post(self.members_url, {"members_old": [membership.id]})
self.client.post(
self.members_url,
{"users_old": self.president.id},
)
self.club.refresh_from_db()
new_membership = self.club.get_membership_for(self.president)
assert new_membership is not None
assert new_membership == membership
membership.refresh_from_db()
membership = self.president.memberships.filter(club=self.club).first()
assert membership.end_date is None
def test_end_membership_with_permission(self):
"""Test that users with permission can end any membership."""
def test_end_membership_as_main_club_board(self):
"""Test that board members of the main club can end the membership
of anyone.
"""
# make subscriber a board member
subscriber = subscriber_user.make()
Membership.objects.create(club=self.ae, user=subscriber, role=3)
nb_memberships = self.club.members.ongoing().count()
self.client.force_login(
subscriber_user.make(
user_permissions=[Permission.objects.get(codename="change_membership")]
)
)
president_membership = self.club.president
self.client.force_login(subscriber)
response = self.client.post(
self.members_url, {"members_old": [president_membership.id]}
self.members_url,
{"users_old": self.president.id},
)
self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(president_membership.user)
self.assert_membership_ended_today(self.president)
assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_root(self):
"""Test that root users can end the membership of anyone."""
nb_memberships = self.club.members.ongoing().count()
self.client.force_login(self.root)
response = self.client.post(
self.members_url,
{"users_old": [self.president.id]},
)
self.assertRedirects(response, self.members_url)
self.assert_membership_ended_today(self.president)
assert self.club.members.ongoing().count() == nb_memberships - 1
def test_end_membership_as_foreigner(self):
@@ -464,11 +421,14 @@ class TestMembership(TestClub):
nb_memberships = self.club.members.count()
membership = self.richard.memberships.filter(club=self.club).first()
self.client.force_login(self.subscriber)
self.client.post(self.members_url, {"members_old": [self.richard.id]})
self.client.post(
self.members_url,
{"users_old": [self.richard.id]},
)
# nothing should have changed
membership.refresh_from_db()
new_mem = self.club.get_membership_for(self.richard)
assert self.club.members.count() == nb_memberships
assert membership.end_date is None
assert membership == new_mem
def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups."""
@@ -530,85 +490,3 @@ class TestMembership(TestClub):
new_board = set(self.club.board_group.users.values_list("id", flat=True))
assert new_members == initial_members
assert new_board == initial_board
@pytest.mark.django_db
class TestJoinClub:
@pytest.fixture(autouse=True)
def clear_cache(self):
cache.clear()
@pytest.mark.parametrize(
("user_factory", "role", "errors"),
[
(
subscriber_user.make,
2,
{
"role": [
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
]
},
),
(
lambda: baker.make(User),
1,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
),
],
)
def test_join_club_errors(
self, user_factory: Callable[[], User], role: int, errors: dict
):
club = baker.make(Club)
user = user_factory()
form = JoinClubForm(club=club, request_user=user, data={"role": role})
assert not form.is_valid()
assert form.errors == errors
def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club)
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make()
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert form.is_valid()
form.save()
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
class TestOldMembersView(TestCase):
@classmethod
def setUpTestData(cls):
club = baker.make(Club)
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
cls.memberships = baker.make(
Membership,
role=iter(roles),
club=club,
start_date=now() - timedelta(days=14),
end_date=now() - timedelta(days=7),
_quantity=len(roles),
_bulk_create=True,
)
cls.url = reverse("club:club_old_members", kwargs={"club_id": club.id})
def test_ok(self):
user = subscriber_user.make()
self.client.force_login(user)
res = self.client.get(self.url)
assert res.status_code == 200
def test_access_forbidden(self):
res = self.client.get(self.url)
assertRedirects(res, reverse("core:login", query={"next": self.url}))
self.client.force_login(baker.make(User))
res = self.client.get(self.url)
assert res.status_code == 403

View File

@@ -1,35 +0,0 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from club.models import Club
from com.models import Poster
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
def test_access(client: Client, route_url):
club = baker.make(Club)
user = subscriber_user.make()
url = reverse(route_url, kwargs={"club_id": club.id})
client.force_login(user)
assert client.get(url).status_code == 403
club.board_group.users.add(user)
assert client.get(url).status_code == 200
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_edit", "club:poster_delete"])
def test_access_specific_poster(client: Client, route_url):
club = baker.make(Club)
user = subscriber_user.make()
poster = baker.make(Poster)
url = reverse(route_url, kwargs={"club_id": club.id, "poster_id": poster.id})
client.force_login(user)
assert client.get(url).status_code == 403
club.board_group.users.add(user)
assert client.get(url).status_code == 200

View File

@@ -25,7 +25,6 @@
from django.urls import path
from club.views import (
ClubAddMembersFragment,
ClubCreateView,
ClubEditView,
ClubListView,
@@ -61,11 +60,6 @@ urlpatterns = [
path("<int:club_id>/edit/", ClubEditView.as_view(), name="club_edit"),
path("<int:club_id>/edit/page/", ClubPageEditView.as_view(), name="club_edit_page"),
path("<int:club_id>/members/", ClubMembersView.as_view(), name="club_members"),
path(
"fragment/<int:club_id>/members/",
ClubAddMembersFragment.as_view(),
name="club_new_members",
),
path(
"<int:club_id>/elderlies/",
ClubOldMembersView.as_view(),

View File

@@ -23,58 +23,52 @@
#
import csv
import itertools
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum
from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
from django.db.models import Sum
from django.http import (
Http404,
HttpResponseRedirect,
StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.safestring import SafeString
from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.functional import cached_property
from django.utils.translation import gettext as _t
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
JoinClubForm,
ClubMemberForm,
MailingForm,
SellingsForm,
)
from club.models import Club, Mailing, MailingSubscription, Membership
from com.models import Poster
from com.views import (
PosterCreateBaseView,
PosterDeleteBaseView,
PosterEditBaseView,
PosterListBaseView,
)
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin
from counter.models import Selling
class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self):
if not hasattr(self, "object") or not self.object:
self.object = self.get_object()
if isinstance(self.object, PageRev):
self.object = self.object.page.club
elif isinstance(self.object, Poster):
self.object = self.object.club
obj = self.get_object()
if isinstance(obj, PageRev):
self.object = obj.page.club
return self.object.get_display_name()
def get_list_of_tabs(self):
@@ -85,7 +79,7 @@ class ClubTabsMixin(TabedViewMixin):
"name": _("Infos"),
}
]
if self.request.user.has_perm("club.view_club"):
if self.request.user.can_view(self.object):
tab_list.extend(
[
{
@@ -165,7 +159,7 @@ class ClubTabsMixin(TabedViewMixin):
"club:poster_list", kwargs={"club_id": self.object.id}
),
"slug": "posters",
"name": _("Posters"),
"name": _("Posters list"),
},
]
)
@@ -177,10 +171,6 @@ class ClubListView(ListView):
model = Club
template_name = "club/club_list.jinja"
queryset = (
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
)
context_object_name = "club_list"
class ClubView(ClubTabsMixin, DetailView):
@@ -234,14 +224,13 @@ class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
class ClubPageHistView(ClubTabsMixin, CanViewMixin, DetailView):
"""Modification hostory of the page."""
model = Club
pk_url_kwarg = "club_id"
template_name = "club/page_history.jinja"
current_tab = "history"
permission_required = "club.view_club"
class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
@@ -253,125 +242,61 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
current_tab = "tools"
class ClubAddMembersFragment(
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView
):
template_name = "club/fragments/add_member.jinja"
model = Membership
object = None
reload_on_redirect = True
permission_required = "club.view_club"
def dispatch(self, *args, **kwargs):
self.club = get_object_or_404(Club, pk=kwargs.get("club_id"))
return super().dispatch(*args, **kwargs)
def get_form_class(self):
user = self.request.user
if user.has_perm("club.add_membership") or self.club.get_membership_for(user):
return ClubAddMemberForm
return JoinClubForm
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"request_user": self.request.user,
"club": self.club,
}
def render_fragment(self, request, **kwargs) -> SafeString:
self.club = kwargs.get("club")
return super().render_fragment(request, **kwargs)
def get_success_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club}
def get_success_message(self, cleaned_data):
if "user" not in cleaned_data or cleaned_data["user"] == self.request.user:
return _("You are now a member of this club.")
return _("%(user)s has been added to club.") % cleaned_data
class ClubMembersView(
ClubTabsMixin, UseFragmentsMixin, PermissionRequiredMixin, DetailFormView
):
class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
"""View of a club's members."""
model = Club
pk_url_kwarg = "club_id"
form_class = ClubOldMemberForm
form_class = ClubMemberForm
template_name = "club/club_members.jinja"
current_tab = "members"
permission_required = "club.view_club"
def get_fragments(self) -> dict[str, type[FragmentMixin] | FragmentRenderer]:
membership = self.object.get_membership_for(self.request.user)
if (
membership
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
and not self.request.user.has_perm("club.add_membership")
):
# Simple club members won't see the form anymore.
# Even if they saw it, they couldn't add anyone to the club anyway
return {}
return {"add_member_fragment": ClubAddMembersFragment}
def get_fragment_data(self) -> dict[str, Any]:
return {"add_member_fragment": {"club": self.object}}
@cached_property
def members(self) -> list[Membership]:
return list(self.object.members.ongoing().order_by("-role"))
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"user": self.request.user,
"club": self.object,
}
kwargs = super().get_form_kwargs()
kwargs["request_user"] = self.request.user
kwargs["club"] = self.object
kwargs["club_members"] = self.members
return kwargs
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
editable = list(
kwargs["form"].fields["members_old"].queryset.values_list("id", flat=True)
)
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("-role")
.select_related("user")
)
kwargs["can_end_membership"] = len(editable) > 0
kwargs["members"] = self.members
return kwargs
def form_valid(self, form):
for membership in form.cleaned_data.get("members_old"):
membership.end_date = now()
"""Check user rights."""
resp = super().form_valid(form)
data = form.clean()
users = data.pop("users", [])
users_old = data.pop("users_old", [])
for user in users:
Membership(club=self.object, user=user, **data).save()
for user in users_old:
membership = self.object.get_membership_for(user)
membership.end_date = timezone.now()
membership.save()
return super().form_valid(form)
return resp
def get_success_url(self, **kwargs):
return self.request.path
class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
"""Old members of a club."""
model = Club
pk_url_kwarg = "club_id"
template_name = "club/club_old_members.jinja"
current_tab = "elderlies"
permission_required = "club.view_club"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("-role", "description", "-end_date")
.select_related("user")
)
}
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""Sales of a club."""
"""Sellings of a club."""
model = Club
pk_url_kwarg = "club_id"
@@ -397,8 +322,9 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
qs = Selling.objects.filter(club=self.object)
kwargs["result"] = Selling.objects.none()
kwargs["result"] = qs[:0]
kwargs["paginated_result"] = kwargs["result"]
kwargs["total"] = 0
kwargs["total_quantity"] = 0
@@ -406,9 +332,8 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
form = self.get_form()
if form.is_valid():
qs = Selling.objects.filter(club=self.object)
if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.none()
qs = Selling.objects.filter(id=-1)
if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]:
@@ -426,18 +351,16 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products)
kwargs["total"] = qs.annotate(
price=F("quantity") * F("unit_price")
).aggregate(total=Sum("price", default=0))["total"]
kwargs["result"] = qs.select_related(
"counter", "counter__club", "customer", "customer__user", "seller"
).order_by("-id")
kwargs["total_quantity"] = qs.aggregate(total=Sum("quantity", default=0))[
"total"
]
kwargs["benefit"] = qs.exclude(product=None).aggregate(
res=Sum("product__purchase_price", default=0)
)["res"]
kwargs["result"] = qs.all().order_by("-id")
kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]])
total_quantity = qs.all().aggregate(Sum("quantity"))
if total_quantity["quantity__sum"]:
kwargs["total_quantity"] = total_quantity["quantity__sum"]
benefit = (
qs.exclude(product=None).all().aggregate(Sum("product__purchase_price"))
)
if benefit["product__purchase_price__sum"]:
kwargs["benefit"] = benefit["product__purchase_price__sum"]
kwargs["paginator"] = Paginator(kwargs["result"], self.paginate_by)
try:
@@ -488,40 +411,40 @@ class ClubSellingCSVView(ClubSellingView):
kwargs = self.get_context_data(**kwargs)
# Use the StreamWriter class instead of request for streaming
writer = csv.writer(self.StreamWriter())
pseudo_buffer = self.StreamWriter()
writer = csv.writer(
pseudo_buffer, delimiter=";", lineterminator="\n", quoting=csv.QUOTE_ALL
)
first_rows = [
[gettext("Quantity"), kwargs["total_quantity"]],
[gettext("Total"), kwargs["total"]],
[gettext("Benefit"), kwargs["benefit"]],
writer.writerow([_t("Quantity"), kwargs["total_quantity"]])
writer.writerow([_t("Total"), kwargs["total"]])
writer.writerow([_t("Benefit"), kwargs["benefit"]])
writer.writerow(
[
gettext("Date"),
gettext("Counter"),
gettext("Barman"),
gettext("Customer"),
gettext("Label"),
gettext("Quantity"),
gettext("Total"),
gettext("Payment method"),
gettext("Selling price"),
gettext("Purchase price"),
gettext("Benefit"),
],
_t("Date"),
_t("Counter"),
_t("Barman"),
_t("Customer"),
_t("Label"),
_t("Quantity"),
_t("Total"),
_t("Payment method"),
_t("Selling price"),
_t("Purchase price"),
_t("Benefit"),
]
)
# Stream response
response = StreamingHttpResponse(
itertools.chain(
(writer.writerow(r) for r in first_rows),
(
writer.writerow(self.write_selling(selling))
for selling in kwargs["result"]
),
),
content_type="text/csv",
)
name = f"{gettext('Sellings')}_{self.object.name}.csv"
response["Content-Disposition"] = f"attachment; filename={name}"
name = _("Sellings") + "_" + self.object.name + ".csv"
response["Content-Disposition"] = "filename=" + name
return response
@@ -759,58 +682,48 @@ class MailingAutoGenerationView(View):
return redirect("club:mailing", club_id=club.id)
class PosterListView(
PermissionOrClubBoardRequiredMixin, ClubTabsMixin, PosterListBaseView
):
class PosterListView(ClubTabsMixin, PosterListBaseView, CanViewMixin):
"""List communication posters."""
current_tab = "posters"
permission_required = "com.view_poster"
def get_queryset(self):
return super().get_queryset().filter(club=self.club.id)
def get_object(self):
return self.club
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"create_url": reverse_lazy(
"club:poster_create", kwargs={"club_id": self.club.id}
),
"get_edit_url": lambda poster: reverse(
"club:poster_edit",
kwargs={"club_id": self.club.id, "poster_id": poster.id},
),
}
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "club"
kwargs["club"] = self.club
return kwargs
class PosterCreateView(ClubTabsMixin, PosterCreateBaseView):
class PosterCreateView(PosterCreateBaseView, CanCreateMixin):
"""Create communication poster."""
current_tab = "posters"
pk_url_kwarg = "club_id"
def get_object(self):
obj = super().get_object()
if not obj:
return self.club
return obj
def get_success_url(self, **kwargs):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
def get_object(self, *args, **kwargs):
return self.club
class PosterEditView(ClubTabsMixin, PosterEditBaseView):
class PosterEditView(ClubTabsMixin, PosterEditBaseView, CanEditMixin):
"""Edit communication poster."""
current_tab = "posters"
extra_context = {"app": "club"}
def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "club"
return kwargs
class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView):
class PosterDeleteView(PosterDeleteBaseView, ClubTabsMixin, CanEditMixin):
"""Delete communication poster."""
current_tab = "posters"
def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})

View File

@@ -5,6 +5,7 @@ from django.utils.cache import add_never_cache_headers
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from api.permissions import HasPerm
@@ -16,13 +17,17 @@ from core.views.files import send_raw_file
@api_controller("/calendar")
class CalendarController(ControllerBase):
@route.get("/internal.ics", auth=None, url_name="calendar_internal")
@route.get("/internal.ics", url_name="calendar_internal")
def calendar_internal(self):
response = send_raw_file(IcsCalendar.get_internal())
add_never_cache_headers(response)
return response
@route.get("/unpublished.ics", url_name="calendar_unpublished")
@route.get(
"/unpublished.ics",
permissions=[IsAuthenticated],
url_name="calendar_unpublished",
)
def calendar_unpublished(self):
response = HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user),
@@ -69,7 +74,6 @@ class NewsController(ControllerBase):
@route.get(
"/date",
auth=None,
url_name="fetch_news_dates",
response=PaginatedResponseSchema[NewsDateSchema],
)

View File

@@ -2,6 +2,7 @@ from datetime import date
from dateutil.relativedelta import relativedelta
from django import forms
from django.db.models import Exists, OuterRef
from django.forms import CheckboxInput
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -34,18 +35,20 @@ class PosterForm(forms.ModelForm):
label=_("Start date"),
widget=SelectDateTime,
required=True,
initial=timezone.now(),
initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
)
date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
def __init__(self, *args, user: User, **kwargs):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if user.is_root or user.is_com_admin:
self.fields["club"].widget = AutoCompleteSelectClub()
else:
self.fields["club"].queryset = Club.objects.having_board_member(user)
if self.user and not self.user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter(
id__in=self.user.clubs_with_rights
)
self.fields.pop("display_time")
class NewsDateForm(forms.ModelForm):
@@ -158,9 +161,16 @@ class NewsForm(forms.ModelForm):
# if the author is an admin, he/she can choose any club,
# otherwise, only clubs for which he/she is a board member can be selected
if author.is_root or author.is_com_admin:
self.fields["club"].widget = AutoCompleteSelectClub()
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.all(), widget=AutoCompleteSelectClub
)
else:
self.fields["club"].queryset = Club.objects.having_board_member(author)
active_memberships = author.memberships.board().ongoing()
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.filter(
Exists(active_memberships.filter(club=OuterRef("pk")))
)
)
def is_valid(self):
return super().is_valid() and self.date_form.is_valid()

View File

@@ -68,7 +68,7 @@ class IcsCalendar:
start=news_date.start_date,
end=news_date.end_date,
url=as_absolute_url(
reverse("com:news_detail", kwargs={"news_id": news_date.news_id})
reverse("com:news_detail", kwargs={"news_id": news_date.news.id})
),
)
calendar.events.append(event)

View File

@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name="newsdate",
constraint=models.CheckConstraint(
condition=models.Q(("end_date__gte", models.F("start_date"))),
check=models.Q(("end_date__gte", models.F("start_date"))),
name="news_date_end_date_after_start_date",
),
),

View File

@@ -27,7 +27,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.db.models import F, Q
from django.shortcuts import render
from django.templatetags.static import static
from django.urls import reverse
@@ -55,17 +55,9 @@ class Sith(models.Model):
class NewsQuerySet(models.QuerySet):
def published(self) -> Self:
def moderated(self) -> Self:
return self.filter(is_published=True)
def waiting_moderation(self) -> Self:
"""Filter all non-finished non-published news"""
# Because of the way News and NewsDates are created,
# there may be some cases where this method is called before
# the NewsDates linked to a Date are actually persisted in db.
# Thus, it's important to filter by "not past date" rather than by "future date"
return self.filter(~Q(dates__start_date__lt=timezone.now()), is_published=False)
def viewable_by(self, user: User) -> Self:
"""Filter news that the given user can view.
@@ -135,28 +127,20 @@ class News(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if not self.is_published:
admins_without_notif = User.objects.filter(
~Exists(
Notification.objects.filter(
user=OuterRef("pk"), type="NEWS_MODERATION"
if self.is_published:
return
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification.objects.create(
user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
)
),
groups__id=settings.SITH_GROUP_COM_ADMIN_ID,
)
notif_url = reverse("com:news_admin_list", fragment="moderation")
new_notifs = [
Notification(user=user, url=notif_url, type="NEWS_MODERATION")
for user in admins_without_notif
]
Notification.objects.bulk_create(new_notifs)
self.update_moderation_notifs()
def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id})
def get_full_url(self):
return f"https://{settings.SITH_URL}{self.get_absolute_url()}"
return "https://%s%s" % (settings.SITH_URL, self.get_absolute_url())
def is_owned_by(self, user):
if user.is_anonymous:
@@ -175,16 +159,19 @@ class News(models.Model):
or (user.is_authenticated and self.author_id == user.id)
)
@staticmethod
def update_moderation_notifs():
count = News.objects.waiting_moderation().count()
notifs_qs = Notification.objects.filter(
type="NEWS_MODERATION", user__groups__id=settings.SITH_GROUP_COM_ADMIN_ID
)
def news_notification_callback(notif: Notification):
# the NewsDate linked to the News
# which creation triggered this callback may not exist yet,
# so it's important to filter by "not past date" rather than by "future date"
count = News.objects.filter(
~Q(dates__start_date__gt=timezone.now()), is_published=False
).count()
if count:
notifs_qs.update(viewed=False, param=str(count))
notif.viewed = False
notif.param = str(count)
else:
notifs_qs.update(viewed=True)
notif.viewed = True
class NewsDateQuerySet(models.QuerySet):
@@ -225,7 +212,7 @@ class NewsDate(models.Model):
verbose_name_plural = _("news dates")
constraints = [
models.CheckConstraint(
condition=Q(end_date__gte=F("start_date")),
check=Q(end_date__gte=F("start_date")),
name="news_date_end_date_after_start_date",
)
]
@@ -402,7 +389,9 @@ class Poster(models.Model):
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification.objects.create(
user=user, url=reverse("com:poster_list"), type="POSTER_MODERATION"
user=user,
url=reverse("com:poster_moderate_list"),
type="POSTER_MODERATION",
)
return super().save(*args, **kwargs)
@@ -410,5 +399,17 @@ class Poster(models.Model):
if self.date_end and self.date_begin > self.date_end:
raise ValidationError(_("Begin date should be before end date"))
def is_owned_by(self, user):
if user.is_anonymous:
return False
return user.is_com_admin or len(user.clubs_with_rights) > 0
def can_be_moderated_by(self, user):
return user.is_com_admin
def get_display_name(self):
return self.club.get_display_name()
@property
def page(self):
return self.club.page

View File

@@ -1,49 +0,0 @@
const INTERVAL = 10;
interface Poster {
url: string; // URL of the poster
displayTime: number; // Number of seconds to display that poster
}
document.addEventListener("alpine:init", () => {
Alpine.data("slideshow", (posters: Poster[]) => ({
posters: posters,
progress: 0,
elapsed: 0,
current: 0,
previous: 0,
init() {
this.$watch("elapsed", () => {
const displayTime = this.posters[this.current].displayTime * 1000;
if (this.elapsed > displayTime) {
this.previous = this.current;
this.current = this.getNext();
this.elapsed = 0;
}
if (displayTime === 0) {
this.progress = 100;
} else {
this.progress = (100 * this.elapsed) / displayTime;
}
});
setInterval(() => {
this.elapsed += INTERVAL;
}, INTERVAL);
},
getNext() {
return (this.current + 1) % this.posters.length;
},
async toggleFullScreen(event: Event) {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
const target = event.target as HTMLElement;
await target.requestFullscreen();
},
}));
});

View File

@@ -83,8 +83,7 @@
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding-bottom: 1em;
height: 20em;
h4 {
margin-left: 5px;

View File

@@ -20,8 +20,34 @@
position: absolute;
display: flex;
bottom: 5px;
&.left {
left: 0;
}
&.right {
right: 0;
}
.link {
padding: 5px;
padding-left: 20px;
padding-right: 20px;
margin-left: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&.delete {
background-color: hsl(0, 100%, 40%);
}
}
}
}
#posters,
@@ -85,7 +111,7 @@
top: 0;
left: 0;
z-index: 10;
content: attr(hover);
content: "Click to expand";
color: white;
background-color: rgba(black, 0.5);
}
@@ -117,15 +143,43 @@
}
}
.actions {
display: flex;
flex-direction: column;
align-items: stretch;
form {
margin: unset;
padding: unset;
button {
width: 100%;
.edit,
.moderate,
.slideshow {
padding: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&:nth-child(2n) {
margin-top: 5px;
margin-bottom: 5px;
}
}
.tooltip {
visibility: hidden;
width: 120px;
background-color: hsl(210, 20%, 98%);
color: hsl(0, 0%, 0%);
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 10;
ul {
margin-left: 0;
display: inline-block;
li {
display: list-item;
list-style-type: none;
}
}
}

View File

@@ -0,0 +1,23 @@
$(document).ready(() => {
$("#poster_list #view").click(() => {
$("#view").removeClass("active");
});
$("#poster_list .poster .image").click((e) => {
let el = $(e.target);
if (el.hasClass("image")) {
el = el.find("img");
}
$("#poster_list #view #placeholder").html(el.clone());
$("#view").addClass("active");
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
$("#view").removeClass("active");
}
});
});

View File

@@ -0,0 +1,98 @@
$(document).ready(() => {
const transitionTime = 1000;
let i = 0;
const max = $("#slideshow .slide").length;
function enterFullscreen() {
const element = document.getElementById("slideshow");
$(element).addClass("fullscreen");
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
}
function exitFullscreen() {
const element = document.getElementById("slideshow");
$(element).removeClass("fullscreen");
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
function initProgressBar() {
$("#slideshow #progress_bar").css("transition", "none");
$("#slideshow #progress_bar").removeClass("progress");
$("#slideshow #progress_bar").addClass("init");
}
function startProgressBar(displayTime) {
$("#slideshow #progress_bar").removeClass("init");
$("#slideshow #progress_bar").addClass("progress");
$("#slideshow #progress_bar").css("transition", `width ${displayTime}s linear`);
}
function next() {
initProgressBar();
const slide = $($("#slideshow .slide").get(i % max));
slide.removeClass("center");
slide.addClass("left");
const nextSlide = $($("#slideshow .slide").get((i + 1) % max));
nextSlide.removeClass("right");
nextSlide.addClass("center");
const displayTime = nextSlide.attr("display_time") || 2;
$("#slideshow .bullet").removeClass("active");
const bullet = $("#slideshow .bullet")[(i + 1) % max];
$(bullet).addClass("active");
i = (i + 1) % max;
setTimeout(() => {
const othersLeft = $("#slideshow .slide.left");
othersLeft.removeClass("left");
othersLeft.addClass("right");
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}, transitionTime);
}
const displayTime = $("#slideshow .center").attr("display_time");
initProgressBar();
setTimeout(() => {
if (max > 1) {
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}
}, 10);
$("#slideshow").click(() => {
if ($("#slideshow").hasClass("fullscreen")) {
exitFullscreen();
} else {
enterFullscreen();
}
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
exitFullscreen();
}
});
});

View File

@@ -34,7 +34,7 @@ body {
z-index: 10;
content: attr(hover);
content: "Click to expand";
color: white;
background-color: rgba(black, 0.5);
@@ -43,7 +43,7 @@ body {
}
&:fullscreen {
&.fullscreen{
position: fixed;
width: 100%;
height: 100%;
@@ -65,59 +65,38 @@ body {
position: relative;
height: 100%;
overflow: hidden;
background-color: grey;
.slide{
position: absolute;
width: 100%;
height: 100%;
display: none;
display: inline-flex;
justify-content: center;
top: 0px;
left: 0%;
background-color: grey;
transition: left 1s ease-out;
img{
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
&.current {
display: inline-flex;
left: 0%;
animation: scrolling-in 1s linear;
}
&.previous {
display: inline-flex;
animation: scrolling-out 1s linear;
opacity: 0;
transition: opacity 0.1s;
transition-delay: 0.9s;
.slide.left{
left: -100%;
}
@keyframes scrolling-in {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(0%);
}
}
@keyframes scrolling-out {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(-100%);
}
.slide.center{
left: 0px;
}
.slide.right{
left: 100%;
transition: none;
}
}
@@ -150,27 +129,21 @@ body {
}
}
progress {
--color: #304c83;
#progress_bar{
position: absolute;
bottom: 0px;
height: 10px;
color: var(--color);
background-color: #304c83;
&.init{
width: 0px;
transition: none;
}
&.progress{
width: 100%;
margin-bottom: 0px;
border: none;
&::-moz-progress-bar {
background: var(--color);
transition: width 10s linear;
}
}
}
&::-webkit-progress-value {
background: var(--color);
}
&[value] {
background-color: transparent;
}
}
}

View File

@@ -76,20 +76,18 @@
It will stay hidden for other users until it has been published.
{% endtrans %}
</p>
{%- if user.has_perm("com.moderate_news") -%}
{% if user.has_perm("com.moderate_news") %}
{# This is an additional query for each non-moderated news,
but it will be executed only for admin users, and only one time
(if they do their job and moderate news as soon as they see them),
(if they do their job and moderated news as soon as they see them),
so it's still reasonable #}
<div
{% if news is integer or news is string -%}
{% if news is integer or news is string %}
x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToPublish()"
{%- elif news.is_published -%}
x-data="{ nbEvents: 0 }"
{%- else -%}
{% else %}
x-data="{ nbEvents: {{ news.dates.count() }} }"
{%- endif -%}
{% endif %}
>
<template x-if="nbEvents > 1">
<div>

View File

@@ -131,7 +131,7 @@
{% endfor %}
</tbody>
</table>
<h5 id="moderation">{% trans %}Events to moderate{% endtrans %}</h5>
<h5>{% trans %}Events to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
@@ -165,3 +165,6 @@
</tbody>
</table>
{% endblock %}

View File

@@ -1,20 +1,15 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, link_news_logo %}
{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
{% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}
{% trans %}News{% endtrans %} - {{ object.title }}
{% trans %}News{% endtrans %} -
{{ object.title }}
{% endblock %}
{% block description %}{{ news.summary }}{% endblock %}
{% block metatags %}
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}News{% endtrans %}" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(link_news_logo(news)) }}" />
{% block head %}
{{ super() }}
{{ gen_news_metatags(news) }}
{% endblock %}
@@ -49,14 +44,8 @@
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
<a
rel="nofollow"
target="#"
class="share_button facebook"
href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}"
>
{% trans %}Share on Facebook{% endtrans %}
</a>
{{ facebook_share(news) }}
{{ tweet(news) }}
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}

View File

@@ -1,6 +1,10 @@
{% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}
{% trans %}News{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
@@ -205,10 +209,6 @@
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-calendar-days fa-xl"></i>
<a href="{{ url("timetable:generator") }}">{% trans %}Timetable{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>

View File

@@ -1,5 +1,11 @@
{% extends "core/base.jinja" %}
{% block script %}
{{ super() }}
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block title %}
{% trans %}Poster{% endtrans %}
{% endblock %}
@@ -9,69 +15,54 @@
{% endblock %}
{% block content %}
<div id="poster_list" x-data="{ active: null }">
<div id="poster_list">
<div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3>
<div id="links">
<a id="create" class="btn btn-blue" href="{{ create_url }}">
<i class="fa fa-plus"></i>
{% trans %}Create{% endtrans %}
</a>
<div id="links" class="right">
{% if app == "com" %}
<a id="create" class="link" href="{{ url(app + ":poster_create") }}">{% trans %}Create{% endtrans %}</a>
<a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a>
{% elif app == "club" %}
<a id="create" class="link" href="{{ url(app + ":poster_create", club.id) }}">{% trans %}Create{% endtrans %}</a>
{% endif %}
</div>
</div>
<div id="posters">
{% if poster_list.count() == 0 %}
<div id="no-posters">{% trans %}No posters{% endtrans %}</div>
{% else %}
{% for poster in poster_list %}
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div class="name">{{ poster.name }}</div>
<div
class="image"
hover="{% trans %}Click to expand{% endtrans %}"
@click="active = $el.firstElementChild"
tooltip="{%- for screen in poster.screens.all() -%}
{{ screen }}
{% endfor %}"
>
<img src="{{ poster.file.url }}" alt="{{ poster.name }}">
</div>
<div class="image"><img src="{{ poster.file.url }}"></img></div>
<div class="dates">
<div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div>
<div class="end">{{ poster.date_end | localtime | date("d/M/Y H:m") }}</div>
</div>
<div class="actions">
{% if poster.is_editable %}
<a class="btn btn-blue" href="{{ get_edit_url(poster) }}">
<i class="fa fa-pen-to-square"></i>
{% trans %}Edit{% endtrans %}
</a>
{% if app == "com" %}
<a class="edit" href="{{ url(app + ":poster_edit", poster.id) }}">{% trans %}Edit{% endtrans %}</a>
{% elif app == "club" %}
<a class="edit" href="{{ url(app + ":poster_edit", club.id, poster.id) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if not poster.is_moderated and user.has_perm("com.moderate_poster") %}
<form action="{{ url("com:poster_moderate", object_id=poster.id) }}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-green">
<i class="fa fa-check"></i>
{% trans %}Moderate{% endtrans %}
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div id="no-posters">{% trans %}No posters{% endtrans %}</div>
<div class="tooltip">
<ul>
{% for screen in poster.screens.all() %}
<li>{{ screen }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endfor %}
{% endif %}
</div>
<div
id="view"
@keyup.escape.window="active = null"
@click="active = null"
:class="{active: active !== null}"
>
<div id="placeholder">
<img :src="active?.src" :alt="active?.name">
</div>
</div>
<div id="view"><div id="placeholder"></div></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "core/base.jinja" %}
{% block script %}
{{ super() }}
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
{% endblock %}
{% block content %}
<div id="poster_list">
<div id="title">
<div id="links" class="left">
<a id="list" class="link" href="{{ url("com:poster_list") }}">{% trans %}List{% endtrans %}</a>
</div>
<h3>{% trans %}Posters - moderation{% endtrans %}</h3>
</div>
<div id="posters">
{% if object_list.count == 0 %}
<div id="no-posters">{% trans %}No objects{% endtrans %}</div>
{% else %}
{% for poster in object_list %}
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div class="name"> {{ poster.name }} </div>
<div class="image"> <img src="{{ poster.file.url }}"></img> </div>
<a class="moderate" href="{{ url("com:poster_moderate", object_id=poster.id) }}">Moderate</a>
</div>
{% endfor %}
{% endif %}
</div>
<div id="view"><div id="placeholder"></div></div>
</div>
{% endblock %}

View File

@@ -2,44 +2,28 @@
<html lang="fr">
<head>
<title>{% trans %}Slideshow{% endtrans %}</title>
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script src="{{ static('com/js/slideshow.js') }}"></script>
</head>
<body x-data="slideshow([
{% for poster in posters %}
{
url: '{{ poster.file.url }}',
displayTime: {{ poster.display_time }}
},
{% endfor %}
])">
<div
id="slideshow"
@click="toggleFullScreen"
hover="{% trans %}Click to expand{% endtrans %}"
@keyup.f.window="toggleFullScreen"
>
<body>
<div id="slideshow">
<div id="slides">
<template x-for="(poster, index) in posters">
<div class="slide" :class="{
current: index === current,
previous: index !== current && index === previous,
}">
<img :src="poster.url">
{% for poster in posters %}
<div class="slide {% if loop.first %}center{% else %}right{% endif %}" display_time="{{ poster.display_time }}">
<img src="{{ poster.file.url }}">
</div>
</template>
{% endfor %}
</div>
<div id="progress_bullets">
<template x-for="(poster, index) in posters">
<div class="bullet" :class="{active: current === index}"></div>
</template>
{% for poster in posters %}
<div class="bullet {% if loop.first %}active{% endif %}"></div>
{% endfor %}
</div>
<progress :value="progress" max="100" x-show="posters.length > 1 && progress > 0"></progress>
<div id="progress_bar"></div>
</div>
</body>

View File

@@ -31,7 +31,9 @@
<td>
<a href="{{ url('com:weekmail_article_edit', article_id=a.id) }}">{% trans %}Edit{% endtrans %}</a> |
<a href="{{ url('com:weekmail_article_delete', article_id=a.id) }}">{% trans %}Delete{% endtrans %}</a> |
<a href="?add_article={{ a.id }}">{% trans %}Add to weekmail{% endtrans %}</a>
<a href="?add_article={{ a.id }}">{% trans %}Add to weekmail{% endtrans %}</a> |
<a href="?up_article={{ a.id }}">{% trans %}Up{% endtrans %}</a> |
<a href="?down_article={{ a.id }}">{% trans %}Down{% endtrans %}</a>
</td>
</tr>
{% endfor %}

View File

@@ -1,22 +1,13 @@
from datetime import timedelta
import pytest
from django.conf import settings
from django.utils.timezone import now
from model_bakery import baker
from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from com.models import News
from core.models import Group, Notification, User
@pytest.mark.django_db
def test_notification_created():
# this news is unpublished, but is set in the past
# it shouldn't be taken into account when counting the number
# of news that are to be moderated
past_news = baker.make(News, is_published=False)
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.users.all().delete()
Notification.objects.all().delete()
@@ -24,28 +15,9 @@ def test_notification_created():
for i in range(2):
# news notifications are permanent, so the notification created
# during the first iteration should be reused during the second one.
baker.make(News, is_published=False)
baker.make(News)
notifications = list(Notification.objects.all())
assert len(notifications) == 1
assert notifications[0].user == com_admin
assert notifications[0].type == "NEWS_MODERATION"
assert notifications[0].param == str(i + 1)
@pytest.mark.django_db
def test_notification_edited_when_moderating_news():
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admins = subscriber_user.make(_quantity=3)
com_admin_group.users.set(com_admins)
Notification.objects.all().delete()
news = baker.make(News, is_published=False)
assert Notification.objects.count() == 3
assert Notification.objects.filter(viewed=False).count() == 3
news.is_published = True
news.moderator = com_admins[0]
news.save()
# when the news is moderated, the notification should be marked as read
# for all admins
assert Notification.objects.count() == 3
assert Notification.objects.filter(viewed=False).count() == 0

View File

@@ -17,13 +17,12 @@ from unittest.mock import patch
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import html
from django.utils.timezone import now
from django.utils.timezone import localtime, now
from django.utils.translation import gettext as _
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
@@ -32,7 +31,6 @@ from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
from core.utils import RED_PIXEL_PNG
@pytest.fixture()
@@ -209,6 +207,31 @@ class TestWeekmailArticle(TestCase):
assert not self.article.is_owned_by(self.sli)
class TestPoster(TestCase):
@classmethod
def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity")
cls.poster = Poster.objects.create(
name="dummy",
file=SimpleUploadedFile("dummy.jpg", b"azertyuiop"),
club=Club.objects.first(),
date_begin=localtime(now()),
)
cls.sli = User.objects.get(username="sli")
cls.sli.memberships.all().delete()
Membership(user=cls.sli, club=Club.objects.first(), role=5).save()
cls.susbcriber = User.objects.get(username="subscriber")
cls.anonymous = AnonymousUser()
def test_poster_owner(self):
"""Test that poster are owned by com admins and board members in clubs."""
assert self.poster.is_owned_by(self.com_admin)
assert not self.poster.is_owned_by(self.anonymous)
assert not self.poster.is_owned_by(self.susbcriber)
assert self.poster.is_owned_by(self.sli)
class TestNewsCreation(TestCase):
@classmethod
def setUpTestData(cls):
@@ -317,6 +340,7 @@ def test_feed(client: Client):
[
reverse("com:poster_list"),
reverse("com:poster_create"),
reverse("com:poster_moderate_list"),
],
)
def test_poster_management_views_crash_test(client: Client, url: str):
@@ -327,37 +351,3 @@ def test_poster_management_views_crash_test(client: Client, url: str):
client.force_login(user)
res = client.get(url)
assert res.status_code == 200
@pytest.mark.django_db
@pytest.mark.parametrize(
"referer",
[
None,
reverse("com:poster_list"),
reverse("club:poster_list", kwargs={"club_id": settings.SITH_MAIN_CLUB_ID}),
],
)
def test_moderate_poster(client: Client, referer: str | None):
poster = baker.make(
Poster,
is_moderated=False,
file=SimpleUploadedFile("test.png", content=RED_PIXEL_PNG),
club_id=settings.SITH_MAIN_CLUB_ID,
)
user = baker.make(
User,
user_permissions=Permission.objects.filter(
codename__in=["view_poster", "moderate_poster"]
),
)
client.force_login(user)
headers = {"REFERER": f"https://{settings.SITH_URL}{referer}"} if referer else {}
response = client.post(
reverse("com:poster_moderate", kwargs={"object_id": poster.id}), headers=headers
)
result_url = referer or reverse("com:poster_list")
assertRedirects(response, result_url)
poster.refresh_from_db()
assert poster.is_moderated
assert poster.moderator == user

View File

@@ -33,6 +33,7 @@ from com.views import (
PosterDeleteView,
PosterEditView,
PosterListView,
PosterModerateListView,
PosterModerateView,
ScreenCreateView,
ScreenDeleteView,
@@ -101,6 +102,11 @@ urlpatterns = [
PosterDeleteView.as_view(),
name="poster_delete",
),
path(
"poster/moderate/",
PosterModerateListView.as_view(),
name="poster_moderate_list",
),
path(
"poster/<int:object_id>/moderate/",
PosterModerateView.as_view(),

View File

@@ -25,17 +25,13 @@ import itertools
from datetime import date, timedelta
from smtplib import SMTPRecipientsRefused
from typing import Any
from urllib.parse import urlparse
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import (
PermissionRequiredMixin,
)
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Exists, Max, OuterRef, Value
from django.db.models import Max
from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
@@ -46,7 +42,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing, Membership
from club.models import Club, Mailing
from com.forms import NewsDateForm, NewsForm, PosterForm
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
@@ -54,10 +50,9 @@ from core.auth.mixins import (
CanEditPropMixin,
CanViewMixin,
PermissionOrAuthorRequiredMixin,
PermissionOrClubBoardRequiredMixin,
)
from core.models import User
from core.views.mixins import TabedViewMixin
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.markdown import MarkdownInput
# Sith object
@@ -104,6 +99,13 @@ class ComTabsMixin(TabedViewMixin):
]
class IsComAdminMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
model = Sith
template_name = "core/edit.jinja"
@@ -335,7 +337,7 @@ class NewsFeed(Feed):
# Weekmail
class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, DetailView):
model = Weekmail
template_name = "com/weekmail_preview.jinja"
success_url = reverse_lazy("com:weekmail")
@@ -347,11 +349,12 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
messages.success(self.request, _("Weekmail sent successfully"))
if request.POST["send"] == "validate":
try:
self.object.send()
return HttpResponseRedirect(reverse("com:weekmail"))
return HttpResponseRedirect(
reverse("com:weekmail") + "?qn_weekmail_send_success"
)
except SMTPRecipientsRefused as e:
self.bad_recipients = e.recipients
elif request.POST["send"] == "clean":
@@ -362,6 +365,7 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
for u in users:
u.preferences.receive_weekmail = False
u.preferences.save()
self.quick_notif_list += ["qn_success"]
return super().get(request, *args, **kwargs)
def get_object(self, queryset=None):
@@ -375,7 +379,7 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
return kwargs
class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView):
model = Weekmail
template_name = "com/weekmail.jinja"
form_class = modelform_factory(
@@ -415,10 +419,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank, prev_art.rank = prev_art.rank, art.rank
art.save()
prev_art.save()
messages.success(
self.request,
_("%(title)s moved up in the Weekmail") % {"title": art.title},
)
self.quick_notif_list += ["qn_success"]
if "down_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["down_article"], weekmail=self.object
@@ -430,10 +431,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank, next_art.rank = next_art.rank, art.rank
art.save()
next_art.save()
messages.success(
self.request,
_("%(title)s moved down in the Weekmail") % {"title": art.title},
)
self.quick_notif_list += ["qn_success"]
if "add_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["add_article"], weekmail=None
@@ -442,10 +440,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank = self.object.articles.aggregate(Max("rank"))["rank__max"] or 0
art.rank += 1
art.save()
messages.success(
self.request,
_("%(title)s added to the Weekmail") % {"title": art.title},
)
self.quick_notif_list += ["qn_success"]
if "del_article" in request.GET:
art = get_object_or_404(
WeekmailArticle, id=request.GET["del_article"], weekmail=self.object
@@ -453,10 +448,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.weekmail = None
art.rank = -1
art.save()
messages.success(
self.request,
_("%(title)s removed from the Weekmail") % {"title": art.title},
)
self.quick_notif_list += ["qn_success"]
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
@@ -466,7 +458,9 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
return kwargs
class WeekmailArticleEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
class WeekmailArticleEditView(
ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView
):
"""Edit an article."""
model = WeekmailArticle
@@ -478,10 +472,11 @@ class WeekmailArticleEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
pk_url_kwarg = "article_id"
template_name = "core/edit.jinja"
success_url = reverse_lazy("com:weekmail")
quick_notif_url_arg = "qn_weekmail_article_edit"
current_tab = "weekmail"
class WeekmailArticleCreateView(CreateView):
class WeekmailArticleCreateView(QuickNotifMixin, CreateView):
"""Post an article."""
model = WeekmailArticle
@@ -492,6 +487,7 @@ class WeekmailArticleCreateView(CreateView):
)
template_name = "core/create.jinja"
success_url = reverse_lazy("core:user_tools")
quick_notif_url_arg = "qn_weekmail_new_article"
def get_initial(self):
if "club" not in self.request.GET:
@@ -562,115 +558,161 @@ class MailingModerateView(View):
raise PermissionDenied
class PosterListBaseView(ListView):
class PosterAdminViewMixin(IsComAdminMixin, ComTabsMixin):
current_tab = "posters"
class PosterListBaseView(PosterAdminViewMixin, ListView):
"""List communication posters."""
current_tab = "posters"
model = Poster
template_name = "com/poster_list.jinja"
permission_required = "com.view_poster"
def dispatch(self, request, *args, **kwargs):
club_id = kwargs.pop("club_id", None)
self.club = None
if club_id:
self.club = get_object_or_404(Club, pk=club_id)
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
qs = Poster.objects.prefetch_related("screens")
if self.request.user.has_perm("com.edit_poster"):
qs = qs.annotate(is_editable=Value(value=True))
if self.request.user.is_com_admin:
return Poster.objects.all().order_by("-date_begin")
else:
qs = qs.annotate(
is_editable=Exists(
Membership.objects.ongoing()
.board()
.filter(user=self.request.user, club=OuterRef("club_id"))
)
)
return qs.order_by("-date_begin")
return Poster.objects.filter(club=self.club.id)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
if not self.request.user.is_com_admin:
kwargs["club"] = self.club
return kwargs
class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView):
class PosterCreateBaseView(PosterAdminViewMixin, CreateView):
"""Create communication poster."""
current_tab = "posters"
form_class = PosterForm
template_name = "core/create.jinja"
permission_required = "com.add_poster"
def get_queryset(self):
return Poster.objects.all()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"user": self.request.user}
def dispatch(self, request, *args, **kwargs):
if "club_id" in kwargs:
self.club = get_object_or_404(Club, pk=kwargs["club_id"])
return super().dispatch(request, *args, **kwargs)
def get_initial(self):
return {"club": self.club}
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club}
kwargs = super().get_context_data(**kwargs)
if not self.request.user.is_com_admin:
kwargs["club"] = self.club
return kwargs
def form_valid(self, form):
if self.request.user.has_perm("com.moderate_poster"):
if self.request.user.is_com_admin:
form.instance.is_moderated = True
return super().form_valid(form)
class PosterEditBaseView(PermissionOrClubBoardRequiredMixin, UpdateView):
class PosterEditBaseView(PosterAdminViewMixin, UpdateView):
"""Edit communication poster."""
pk_url_kwarg = "poster_id"
current_tab = "posters"
form_class = PosterForm
template_name = "com/poster_edit.jinja"
permission_required = "com.change_poster"
def get_initial(self):
return {
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_begin
else None,
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_end
else None,
}
def dispatch(self, request, *args, **kwargs):
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
raise PermissionDenied from e
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return Poster.objects.all()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"user": self.request.user}
kwargs = super().get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club}
kwargs = super().get_context_data(**kwargs)
if hasattr(self, "club"):
kwargs["club"] = self.club
return kwargs
def form_valid(self, form):
if not self.request.user.has_perm("com.moderate_poster"):
if self.request.user.is_com_admin:
form.instance.is_moderated = False
return super().form_valid(form)
class PosterDeleteBaseView(
PermissionOrClubBoardRequiredMixin, ComTabsMixin, DeleteView
):
class PosterDeleteBaseView(PosterAdminViewMixin, DeleteView):
"""Edit communication poster."""
pk_url_kwarg = "poster_id"
current_tab = "posters"
model = Poster
template_name = "core/delete_confirm.jinja"
permission_required = "com.delete_poster"
def dispatch(self, request, *args, **kwargs):
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
raise PermissionDenied from e
return super().dispatch(request, *args, **kwargs)
class PosterListView(PermissionRequiredMixin, ComTabsMixin, PosterListBaseView):
class PosterListView(PosterListBaseView):
"""List communication posters."""
current_tab = "posters"
extra_context = {
"create_url": reverse_lazy("com:poster_create"),
"get_edit_url": lambda poster: reverse(
"com:poster_edit", kwargs={"poster_id": poster.id}
),
}
permission_required = "com.view_poster"
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterCreateView(ComTabsMixin, PosterCreateBaseView):
class PosterCreateView(PosterCreateBaseView):
"""Create communication poster."""
current_tab = "posters"
success_url = reverse_lazy("com:poster_list")
extra_context = {"app": "com"}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterEditView(ComTabsMixin, PosterEditBaseView):
class PosterEditView(PosterEditBaseView):
"""Edit communication poster."""
current_tab = "posters"
success_url = reverse_lazy("com:poster_list")
extra_context = {"app": "com"}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterDeleteView(PosterDeleteBaseView):
@@ -679,37 +721,44 @@ class PosterDeleteView(PosterDeleteBaseView):
success_url = reverse_lazy("com:poster_list")
class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
"""Moderate communication poster."""
class PosterModerateListView(PosterAdminViewMixin, ListView):
"""Moderate list communication poster."""
current_tab = "posters"
permission_required = "com.moderate_poster"
extra_context = {"app": "com"}
model = Poster
template_name = "com/poster_moderate.jinja"
queryset = Poster.objects.filter(is_moderated=False).all()
def post(self, request, *args, **kwargs):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterModerateView(PosterAdminViewMixin, View):
"""Moderate communication poster."""
def get(self, request, *args, **kwargs):
obj = get_object_or_404(Poster, pk=kwargs["object_id"])
if obj.can_be_moderated_by(request.user):
obj.is_moderated = True
obj.moderator = request.user
obj.save()
# The moderation request may be originated from a club context (/club/poster)
# or a global context (/com/poster),
# so the redirection URL will be the URL of the page that called this view,
# as long as the latter belongs to the sith.
referer = self.request.META.get("HTTP_REFERER")
if referer:
parsed = urlparse(referer)
if parsed.netloc == settings.SITH_URL:
return redirect(parsed.path)
return redirect("com:poster_list")
return redirect("com:poster_moderate_list")
raise PermissionDenied
def get_context_data(self, **kwargs):
kwargs = super(PosterModerateListView, self).get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):
class ScreenListView(IsComAdminMixin, ComTabsMixin, ListView):
"""List communication screens."""
current_tab = "screens"
model = Screen
template_name = "com/screen_list.jinja"
permission_required = "com.view_screen"
class ScreenSlideshowView(DetailView):
@@ -720,12 +769,12 @@ class ScreenSlideshowView(DetailView):
template_name = "com/screen_slideshow.jinja"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"posters": self.object.active_posters()
}
kwargs = super().get_context_data(**kwargs)
kwargs["posters"] = self.object.active_posters()
return kwargs
class ScreenCreateView(PermissionRequiredMixin, ComTabsMixin, CreateView):
class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView):
"""Create communication screen."""
current_tab = "screens"
@@ -733,10 +782,9 @@ class ScreenCreateView(PermissionRequiredMixin, ComTabsMixin, CreateView):
fields = ["name"]
template_name = "core/create.jinja"
success_url = reverse_lazy("com:screen_list")
permission_required = "com.add_screen"
class ScreenEditView(PermissionRequiredMixin, ComTabsMixin, UpdateView):
class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView):
"""Edit communication screen."""
pk_url_kwarg = "screen_id"
@@ -745,10 +793,9 @@ class ScreenEditView(PermissionRequiredMixin, ComTabsMixin, UpdateView):
fields = ["name"]
template_name = "com/screen_edit.jinja"
success_url = reverse_lazy("com:screen_list")
permission_required = "com.change_screen"
class ScreenDeleteView(PermissionRequiredMixin, ComTabsMixin, DeleteView):
class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView):
"""Delete communication screen."""
pk_url_kwarg = "screen_id"
@@ -756,4 +803,3 @@ class ScreenDeleteView(PermissionRequiredMixin, ComTabsMixin, DeleteView):
model = Screen
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:screen_list")
permission_required = "com.delete_screen"

View File

@@ -88,9 +88,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date", "is_in_sas")
list_display = ("name", "owner", "size", "date")
autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name", "parent__name")
search_fields = ("name",)
@admin.register(OperationLog)

View File

@@ -25,7 +25,6 @@ from core.schemas import (
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
UserSchema,
)
from core.templatetags.renderer import markdown
@@ -70,22 +69,16 @@ class MailingListController(ControllerBase):
return data
@api_controller("/user")
@api_controller("/user", permissions=[CanAccessLookup])
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
@route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
"""Fetch a single user"""
return self.get_object_or_exception(User, id=user_id)
@route.get(
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
@@ -99,12 +92,12 @@ class SithFileController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SithFileSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
return SithFile.objects.filter(name__icontains=search)
@api_controller("/group")
@@ -112,7 +105,7 @@ class GroupController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[GroupSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)

View File

@@ -24,18 +24,13 @@
from __future__ import annotations
import types
import warnings
from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from django.views.generic.base import View
from club.models import Club
if TYPE_CHECKING:
from django.db.models import Model
@@ -146,6 +141,45 @@ class GenericContentPermissionMixinBuilder(View):
return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def __init_subclass__(cls, **kwargs):
warnings.warn(
f"{cls.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init_subclass__(**kwargs)
def __init__(self, *args, **kwargs):
warnings.warn(
f"{self.__class__.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs):
if not request.user.is_authenticated:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object.
@@ -263,50 +297,3 @@ class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
self.author_field += "_id"
author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id
class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the board of the club.
This mixin can be used in any view that is called from a url
having a `club_id` kwarg.
Example:
In `urls.py` :
```python
urlpatterns = [
path("foo/<int:club_id>/bar/", FooView.as_view())
]
```
In `views.py` :
```python
# this view is available to users that either have the
# "foo.view_foo" permission or are in the board of the club
# which id was given in the url
class FooView(PermissionOrClubBoardRequiredMixin, View):
permission_required = "foo.view_foo"
```
"""
club_pk_url_kwarg = "club_id"
@cached_property
def club(self):
club_id: str | int = self.kwargs.pop(self.club_pk_url_kwarg, None)
if club_id is None:
return None
if isinstance(club_id, int) or club_id.isdigit():
return get_object_or_404(Club, pk=club_id)
raise Http404(_("No club found with id %(id)s") % {"id": club_id})
def has_permission(self):
if self.request.user.is_anonymous:
return False
if super().has_permission():
return True
return self.club is not None and any(
g.id == self.club.board_group_id for g in self.request.user.cached_groups
)

View File

@@ -1,41 +0,0 @@
import pathlib
from django.apps import apps
from django.core.management.base import BaseCommand
from PIL import Image, UnidentifiedImageError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("number", type=int)
parser.add_argument("path", type=pathlib.Path)
parser.add_argument("-f", "--force", action="store_true")
def handle(self, number: int, path: pathlib.Path, force: int, *args, **options):
if not path.exists() or path.is_dir():
self.stderr.write(f"{path} is not a file or does not exist")
return
dest_path = (
pathlib.Path(apps.get_app_config("core").path)
/ "static"
/ "core"
/ "img"
/ f"promo_{number}.png"
)
if dest_path.exists() and not force:
over = input("File already exists, do you want to overwrite it? (y/N):")
if over.lower() != "y":
self.stdout.write("exiting")
return
try:
im = Image.open(path)
im.resize((120, 120), resample=Image.Resampling.LANCZOS).save(
dest_path, format="PNG"
)
self.stdout.write(
f"Promo logo moved and resized successfully at {dest_path}"
)
except UnidentifiedImageError:
self.stderr.write("image cannot be opened and identified.")

View File

@@ -0,0 +1,40 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively check the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._check_fs()

View File

@@ -110,7 +110,6 @@ class Command(BaseCommand):
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
@@ -693,33 +692,21 @@ class Command(BaseCommand):
# SAS
for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir():
album = Album(
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
album = Album.objects.create(name=f.name, is_moderated=True)
for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture(
parent=album,
name=p.name,
file=file,
original=file,
owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True,
mime_type="image/webp",
size=file.size,
)
pict.file.name = p.name
pict.full_clean()
pict.original.name = pict.name
pict.generate_thumbnails()
pict.full_clean()
pict.save()
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@@ -768,7 +755,7 @@ class Command(BaseCommand):
s = Subscription(
member=user,
subscription_type=subscription_type,
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
)
s.subscription_start = s.compute_start(start)
s.subscription_end = s.compute_end(

View File

@@ -94,11 +94,7 @@ class Command(BaseCommand):
username=self.faker.user_name(),
first_name=self.faker.first_name(),
last_name=self.faker.last_name(),
date_of_birth=(
None
if random.random() < 0.2
else self.faker.date_of_birth(minimum_age=15, maximum_age=25)
),
date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
email=self.faker.email(),
phone=self.faker.phone_number(),
address=self.faker.address(),

View File

@@ -0,0 +1,41 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively repair the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._repair_fs()

View File

@@ -154,7 +154,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name="userban",
constraint=models.CheckConstraint(
condition=models.Q(("expires_at__gte", models.F("created_at"))),
check=models.Q(("expires_at__gte", models.F("created_at"))),
name="user_ban_end_after_start",
),
),

View File

@@ -0,0 +1,27 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0047_alter_notification_date_alter_notification_type"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]

View File

@@ -0,0 +1,9 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0048_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]

View File

@@ -23,12 +23,14 @@
#
from __future__ import annotations
import logging
import os
import string
import unicodedata
from datetime import timedelta
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Optional, Self
from uuid import uuid4
from django.conf import settings
@@ -95,6 +97,48 @@ def validate_promo(value: int) -> None:
)
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:
"""Search for a group by its primary key or its name.
Either one of the two must be set.
The result is cached for the default duration (should be 5 minutes).
Args:
pk: The primary key of the group
name: The name of the group
Returns:
The group if it exists, else None
Raises:
ValueError: If no group matches the criteria
"""
if pk is None and name is None:
raise ValueError("Either pk or name must be set")
# replace space characters to hide warnings with memcached backend
pk_or_name: str | int = pk if pk is not None else name.replace(" ", "_")
group = cache.get(f"sith_group_{pk_or_name}")
if group == "not_found":
# Using None as a cache value is a little bit tricky,
# so we use a special string to represent None
return None
elif group is not None:
return group
# if this point is reached, the group is not in cache
if pk is not None:
group = Group.objects.filter(pk=pk).first()
else:
group = Group.objects.filter(name=name).first()
if group is not None:
name = group.name.replace(" ", "_")
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
else:
cache.set(f"sith_group_{pk_or_name}", "not_found")
return group
class BanGroup(AuthGroup):
"""An anti-group, that removes permissions instead of giving them.
@@ -338,18 +382,19 @@ class User(AbstractUser):
Returns:
True if the user is the group, else False
"""
if not pk and not name:
if pk is not None:
group: Optional[Group] = get_group(pk=pk)
elif name is not None:
group: Optional[Group] = get_group(name=name)
else:
raise ValueError("You must either provide the id or the name of the group")
group_id: int | None = (
pk or Group.objects.filter(name=name).values_list("id", flat=True).first()
)
if group_id is None:
if group is None:
return False
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed
if group_id == settings.SITH_GROUP_ROOT_ID:
if group.id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return any(g.id == group_id for g in self.cached_groups)
return group in self.cached_groups
@cached_property
def cached_groups(self) -> list[Group]:
@@ -409,6 +454,14 @@ class User(AbstractUser):
else:
raise ValidationError(_("A user with that username already exists"))
def get_profile(self):
return {
"last_name": self.last_name,
"first_name": self.first_name,
"nick_name": self.nick_name,
"date_of_birth": self.date_of_birth,
}
def get_short_name(self):
"""Returns the short name for the user."""
if self.nick_name:
@@ -507,7 +560,7 @@ class User(AbstractUser):
"""Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
return True
return self.is_root
@@ -516,14 +569,8 @@ class User(AbstractUser):
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True
if hasattr(obj, "edit_groups"):
if (
hasattr(obj, "_prefetched_objects_cache")
and "edit_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.edit_groups.all()]
else:
pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
for pk in obj.edit_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
if isinstance(obj, User) and obj == self:
return True
@@ -534,17 +581,8 @@ class User(AbstractUser):
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
if hasattr(obj, "view_groups"):
# if "view_groups" has already been prefetched, use
# the prefetch cache, else fetch only the ids, to make
# the query lighter.
if (
hasattr(obj, "_prefetched_objects_cache")
and "view_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.view_groups.all()]
else:
pks = list(obj.view_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
for pk in obj.view_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
return self.can_edit(obj)
@@ -598,6 +636,9 @@ class User(AbstractUser):
class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
@property
def was_subscribed(self):
return False
@@ -606,6 +647,10 @@ class AnonymousUser(AuthAnonymousUser):
def is_subscribed(self):
return False
@property
def subscribed(self):
return False
@property
def is_root(self):
return False
@@ -636,8 +681,8 @@ class AnonymousUser(AuthAnonymousUser):
if pk is not None:
return pk == allowed_id
elif name is not None:
group = Group.objects.get(id=allowed_id)
return group.name == name
group = get_group(name=name)
return group is not None and group.id == allowed_id
else:
raise ValueError("You must either provide the id or the name of the group")
@@ -700,7 +745,7 @@ class UserBan(models.Model):
fields=["ban_group", "user"], name="unique_ban_type_per_user"
),
models.CheckConstraint(
condition=Q(expires_at__gte=F("created_at")),
check=Q(expires_at__gte=F("created_at")),
name="user_ban_end_after_start",
),
]
@@ -818,9 +863,6 @@ class SithFile(models.Model):
on_delete=models.CASCADE,
)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta:
verbose_name = _("file")
@@ -829,22 +871,10 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs):
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
adding = self._state.adding
super().save(*args, **kwargs)
if adding:
self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
@@ -857,8 +887,6 @@ class SithFile(models.Model):
return user.is_board_member
if user.is_com_admin:
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool:
@@ -885,8 +913,6 @@ class SithFile(models.Model):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or (
self.parent is not None and self in self.get_parent_list()
):
@@ -963,22 +989,67 @@ class SithFile(models.Model):
self.clean()
self.save()
def _repair_fs(self):
"""Rebuilds recursively the filesystem as it should be regarding the DB tree."""
if self.is_folder:
for c in self.children.all():
c._repair_fs()
return
elif not self._check_path_consistence():
# First get future parent path and the old file name
# Prepend "." so that we match all relative handling of Django's
# file storage
parent_path = "." + self.parent.get_full_path()
parent_full_path = settings.MEDIA_ROOT + parent_path
os.makedirs(parent_full_path, exist_ok=True)
old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg"
new_path = "." + self.get_full_path()
try:
# Make this atomic, so that a FS problem rolls back the DB change
with transaction.atomic():
# Set the new filesystem path
self.file.name = new_path
self.save()
# Really move at the FS level
if os.path.exists(parent_full_path):
os.rename(
settings.MEDIA_ROOT + old_path,
settings.MEDIA_ROOT + new_path,
)
# Empty directories may remain, but that's not really a
# problem, and that can be solved with a simple shell
# command: `find . -type d -empty -delete`
except Exception as e:
logging.error(e)
def _check_path_consistence(self):
file_path = str(self.file)
file_full_path = settings.MEDIA_ROOT + file_path
db_path = ".%s" % self.get_full_path()
if not os.path.exists(file_full_path):
print("%s: WARNING: real file does not exists!" % self.id) # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
if file_path != db_path:
print("%s: " % self.id, end="") # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
return True
def _check_fs(self):
if self.is_folder:
for c in self.children.all():
c._check_fs()
return
else:
self._check_path_consistence()
@property
def is_file(self):
return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self):
parents = []
current = self.parent
@@ -1047,6 +1118,8 @@ class QuickUploadImage(models.Model):
identifier = str(uuid4())
name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1]
file = File(convert_image(image), name=f"{identifier}.webp")
width, height = Image.open(file).size
return cls.objects.create(
uuid=identifier,
name=name,
@@ -1078,18 +1151,6 @@ class NotLocked(LockError):
pass
class PageQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.is_anonymous:
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
groups_ids = [g.id for g in user.cached_groups]
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
# This function prevents generating migration upon settings change
def get_default_owner_group():
return settings.SITH_GROUP_ROOT_ID
@@ -1159,8 +1220,6 @@ class Page(models.Model):
_("lock_timeout"), null=True, blank=True, default=None
)
objects = PageQuerySet.as_manager()
class Meta:
unique_together = ("name", "parent")
permissions = (
@@ -1170,9 +1229,12 @@ class Page(models.Model):
def __str__(self):
return self.get_full_name()
def save(self, *args, force_lock: bool = False, **kwargs):
def save(self, *args, **kwargs):
"""Performs some needed actions before and after saving a page in database."""
if not force_lock and not self.is_locked():
locked = kwargs.pop("force_lock", False)
if not locked:
locked = self.is_locked()
if not locked:
raise NotLocked("The page is not locked and thus can not be saved")
self.full_clean()
if not self.id:
@@ -1184,7 +1246,7 @@ class Page(models.Model):
# It also update all the children to maintain correct names
self._full_name = self.get_full_name()
for c in self.children.all():
c.save(force_lock=force_lock)
c.save()
super().save(*args, **kwargs)
self.unset_lock()
@@ -1291,23 +1353,23 @@ class Page(models.Model):
@cached_property
def is_club_page(self):
return (
self.name == settings.SITH_CLUB_ROOT_PAGE
or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()]
club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
return club_root_page is not None and (
self == club_root_page or club_root_page in self.get_parent_list()
)
@cached_property
def need_club_redirection(self):
return self.is_club_page and self.name != settings.SITH_CLUB_ROOT_PAGE
def delete(self, *args, **kwargs):
def delete(self):
self.unset_lock_recursive()
self.set_lock_recursive(User.objects.get(id=0))
for child in self.children.all():
child.parent = self.parent
child.save()
child.unset_lock_recursive()
return super().delete(*args, **kwargs)
super().delete()
class PageRev(models.Model):
@@ -1354,12 +1416,9 @@ class PageRev(models.Model):
def get_absolute_url(self):
return reverse("core:page", kwargs={"page_name": self.page._full_name})
def can_be_edited_by(self, user: User) -> bool:
def can_be_edited_by(self, user):
return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def get_notification_types():
return settings.SITH_NOTIFICATIONS

View File

@@ -34,22 +34,6 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"]
class UserSchema(ModelSchema):
class Meta:
model = User
fields = [
"id",
"nick_name",
"first_name",
"last_name",
"date_of_birth",
"email",
"role",
"quote",
"promo",
]
class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile"""

View File

@@ -1,10 +1,7 @@
import { limitedChoices } from "#core:alpine/limited-choices";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
Alpine.plugin([sort, limitedChoices]);
Alpine.magic("notifications", notificationPlugin);
Alpine.plugin(sort);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {

View File

@@ -1,69 +0,0 @@
import type { Alpine as AlpineType } from "alpinejs";
export function limitedChoices(Alpine: AlpineType) {
/**
* Directive to limit the number of elements
* that can be selected in a group of checkboxes.
*
* When the max numbers of selectable elements is reached,
* new elements will still be inserted, but oldest ones will be deselected.
* For example, if checkboxes A, B and C have been selected and the max
* number of selections is 3, then selecting D will result in having
* B, C and D selected.
*
* # Example in template
* ```html
* <div x-data="{nbMax: 2}", x-limited-choices="nbMax">
* <button @click="nbMax += 1">Click me to increase the limit</button>
* <input type="checkbox" value="A" name="foo">
* <input type="checkbox" value="B" name="foo">
* <input type="checkbox" value="C" name="foo">
* <input type="checkbox" value="D" name="foo">
* </div>
* ```
*/
Alpine.directive(
"limited-choices",
(el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater(expression);
let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"),
);
const checked = [] as HTMLInputElement[];
const manageDequeue = () => {
if (checked.length <= maxChoices) {
// There isn't too many checkboxes selected. Nothing to do
return;
}
const popped = checked.splice(0, checked.length - maxChoices);
for (const p of popped) {
p.checked = false;
}
};
for (const input of inputs) {
input.addEventListener("change", (_e) => {
if (input.checked) {
checked.push(input);
} else {
checked.splice(checked.indexOf(input), 1);
}
manageDequeue();
});
}
effect(() => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value);
if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered.
// Some currently selected elements may need to be removed
manageDequeue();
}
});
});
},
);
}

View File

@@ -1,36 +0,0 @@
export enum NotificationLevel {
Error = "error",
Warning = "warning",
Success = "success",
}
export function createNotification(message: string, level: NotificationLevel) {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(
new CustomEvent("quick-notification-add", {
detail: { text: message, tag: level },
}),
);
}
export function deleteNotifications() {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(new CustomEvent("quick-notification-delete"));
}
export function alpinePlugin() {
return {
error: (message: string) => createNotification(message, NotificationLevel.Error),
warning: (message: string) =>
createNotification(message, NotificationLevel.Warning),
success: (message: string) =>
createNotification(message, NotificationLevel.Success),
clear: () => deleteNotifications(),
};
}

View File

@@ -36,7 +36,6 @@
> .ts-control {
box-shadow: none;
max-width: 300px;
width: 300px;
background-color: var(--nf-input-background-color);
&::after {

View File

@@ -65,7 +65,7 @@ footer.bottom-links {
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: black 0 8px 15px;
box-shadow: $shadow-color 0 0 15px;
a {
color: $white-color;

View File

@@ -47,7 +47,6 @@
}
input,
select,
textarea[type="text"],
[type="number"],
.ts-control {
@@ -154,9 +153,11 @@ form {
margin-bottom: 1rem;
}
.row > label {
.row {
label {
margin: unset;
}
}
// ------------- LABEL
label, legend {
@@ -239,23 +240,6 @@ form {
}
}
}
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="url"],
input[type="password"],
input[type="number"],
input[type="date"],
input[type="datetime-local"],
input[type="week"],
input[type="time"],
input[type="month"],
input[type="search"],
textarea,
select,
.ts-control {
min-height: calc(var(--nf-input-size) * 2.5);
}
input[type="text"],
input[type="checkbox"],

View File

@@ -11,8 +11,7 @@ $hovered-red-text-color: #ff4d4d;
.header {
box-sizing: border-box;
background-color: $deepblue;
box-shadow: black 0 1px 3px 0,
black 0 4px 8px 3px;
box-shadow: 3px 3px 3px 0 #dfdfdf;
border-radius: 0;
width: 100%;
display: flex;
@@ -100,7 +99,7 @@ $hovered-red-text-color: #ff4d4d;
border-radius: 0;
margin: 0;
box-sizing: border-box;
background-color: transparent;
background-color: $deepblue;
width: 45px;
height: 25px;
padding: 0;
@@ -322,6 +321,7 @@ $hovered-red-text-color: #ff4d4d;
>#header_notif {
box-sizing: border-box;
display: none;
position: absolute;
margin: 0;
background-color: whitesmoke;
@@ -332,7 +332,7 @@ $hovered-red-text-color: #ff4d4d;
padding: 10px;
z-index: 100;
border-radius: 10px;
@include shadow;
box-shadow: 3px 3px 3px 0 #767676;
>ul {
list-style-type: none;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,38 @@
$(() => {
$("#quick_notif li").click(function () {
$(this).hide();
});
});
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function createQuickNotif(msg) {
const el = document.createElement("li");
el.textContent = msg;
el.addEventListener("click", () => el.parentNode.removeChild(el));
document.getElementById("quick_notif").appendChild(el);
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function deleteQuickNotifs() {
const el = document.getElementById("quick_notif");
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function displayNotif() {
$("#header_notif").toggle().parent().toggleClass("white");
}
// You can't get the csrf token from the template in a widget
// We get it from a cookie as a workaround, see this link
// https://docs.djangoproject.com/en/2.0/ref/csrf/#ajax
// Sadly, getting the cookie is not possible with CSRF_COOKIE_HTTPONLY or CSRF_USE_SESSIONS is True
// So, the true workaround is to get the token from the dom
// https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true
// biome-ignore lint/style/useNamingConvention: can't find it used anywhere but I will not play with the devil
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val();
}

View File

@@ -270,10 +270,20 @@ body {
}
/*--------------------------------CONTENT------------------------------*/
#quick_notif {
width: 100%;
margin: 0 auto;
list-style-type: none;
background: $second-color;
li {
padding: 10px;
}
}
#content {
padding: 1.5em 2%;
border-radius: 5px;
box-shadow: black 0 8px 15px;
padding: 1em 1%;
box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
overflow: auto;
}
@@ -504,17 +514,9 @@ th {
text-align: center;
padding: 5px 10px;
>input[type="checkbox"] {
padding: unset;
}
>ul {
margin-top: 0;
}
>input[type="checkbox"] {
padding: unset;
}
}
td {

View File

@@ -2,24 +2,8 @@
<html lang="fr">
<head>
{% block head %}
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta
name="description"
content="{% block description -%}
{% trans trimmed %}
AE UTBM is a voluntary organisation run by UTBM students.
It organises student life at UTBM and manages its student facilities.
{% endtrans %}
{%- endblock %}"
>
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Association des Étudiants de l'UTBM" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
@@ -42,20 +26,12 @@
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script src="{{ static('core/js/script.js') }}"></script>
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
<style>
{# background image must be declared here, because the static names are
changed during the static collection step,
which means we must gather them with the `static` template function #}
.header {
background-image: url("{{ static("core/img/gala25_background.webp") }}");
background-position-y: 80%; {# There are more stars in this part of the picture #}
}
body {
background-image: url("{{ static("core/img/gala25_background.webp") }}");
}
</style>
{% endblock %}
</head>
@@ -92,15 +68,17 @@
<div id="page">
<ul id="quick_notif">
{% for n in quick_notifs %}
<li>{{ n }}</li>
{% endfor %}
</ul>
<div id="content">
{%- block tabs -%}
{% include "core/base/tabs.jinja" %}
{%- endblock -%}
{% block notifications %}
{% include "core/base/notifications.jinja" %}
{% endblock %}
{%- block errors -%}
{% if error %}
{{ error }}
@@ -117,6 +95,16 @@
{% endblock %}
{% block script %}
<script>
document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
return;
}
document.getElementById("search").focus();
e.preventDefault(); // Don't type the character in the focused search input
})
</script>
{% endblock %}
</body>
</html>

View File

@@ -1,6 +1,6 @@
<header class="header">
<div class="header-logo">
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static("core/img/gala25_logo.webp") }}')">
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')">
&nbsp;
</a>
<a class="header-logo-text" href="{{ url('core:index') }}">
@@ -74,25 +74,25 @@
{% endif %}
></a>
</div>
<div class="notification" x-data="{display: false}" :class="{white: display}">
<a href="#" @click.prevent="display = !display">
<i :class="`fa-${display ? 'solid': 'regular'} fa-bell`" x-transition></i>
{% set notifications = user.notifications.filter(viewed=False).order_by("-date")|list %}
<div class="notification">
<a href="#" onclick="displayNotif()">
<i class="fa-regular fa-bell"></i>
{% set notification_count = user.notifications.filter(viewed=False).count() %}
{%- if notifications|length > 0 -%}
{% if notification_count > 0 %}
<span>
{% if notifications|length < 100 %}
{{ notifications|length }}
{%- else -%}
99+
{%- endif -%}
{% if notification_count < 100 %}
{{ notification_count }}
{% else %}
&nbsp;
{% endif %}
</span>
{% endif %}
</a>
<div id="header_notif" x-show="display" x-cloak x-transition @click.outside="display = false">
<div id="header_notif">
<ul>
{%- if notifications|length > 0 -%}
{%- for n in notifications -%}
{% if user.notifications.filter(viewed=False).count() > 0 %}
{% for n in user.notifications.filter(viewed=False).order_by('-date') %}
<li>
<a href="{{ url("core:notification", notif_id=n.id) }}">
<div class="datetime">
@@ -108,10 +108,10 @@
</div>
</a>
</li>
{%- endfor -%}
{%- else -%}
{% endfor %}
{% else %}
<li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li>
{%- endif -%}
{% endif %}
</ul>
<div class="options">
<a href="{{ url('core:notification_list') }}">

View File

@@ -1,24 +0,0 @@
<div id="quick-notifications"
x-data="{
messages: [
{% if messages %}
{% for message in messages %}
{
tag: '{{ message.tags }}',
text: '{{ message }}',
},
{% endfor %}
{% endif %}
]
}"
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="(message, index) in messages">
<div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
<i class="fa fa-close"></i>
</span>
</div>
</template>
</div>

View File

@@ -21,6 +21,20 @@
{% else %}
<h2>{% trans %}Save{% endtrans %}</h2>
{% endif %}
{% if messages %}
<div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
<span class="alert-main">
{% for message in messages %}
{% if message.level_tag == "success" %}
{{ message }}
{% endif %}
{% endfor %}
</span>
<span class="clickable" @click="show_alert = false">
<i class="fa fa-close"></i>
</span>
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}

View File

@@ -15,7 +15,6 @@
{{ select_all_checkbox("add_users") }}
<hr>
{% csrf_token %}
{{ form.non_field_errors() }}
<label for="{{ form.users_removed.id_for_label }}">{{ form.users_removed.label }} :</label>
{{ form.users_removed.errors }}
{% for user in form.users_removed %}

View File

@@ -13,11 +13,30 @@
{%- endmacro %}
{% macro link_news_logo(news) -%}
{%- if news.club.logo -%}
{% if news.club.logo -%}
{{ news.club.logo.url }}
{%- else -%}
{% else -%}
{{ static("com/img/news.png") }}
{%- endif -%}
{% endif %}
{%- endmacro %}
{% macro gen_news_metatags(news) -%}
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{{ settings.SITH_TWITTER }}" />
<meta name="twitter:creator" content= "{{ settings.SITH_TWITTER }}" />
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ "https://%s%s" % (settings.SITH_URL, link_news_logo(news)) }}" />
{%- endmacro %}
{% macro facebook_share(news) -%}
<a rel="nofollow" target="#" class="share_button facebook" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}">{% trans %}Share on Facebook{% endtrans %}</a>
{%- endmacro %}
{% macro tweet(news) -%}
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endmacro %}
{% macro user_mini_profile(user) %}
@@ -226,26 +245,3 @@
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% endmacro %}
{% macro update_notifications(messages, clear) %}
{# Update notification area from new messages sent by django backend
This is useful when performing fragment swaps to keep messages up to date
Without this, the fragment would need to take control of the notification area and
this would be an issue when having more than one fragment
Parameters:
messages: messages from django.contrib
clear : optional boolean that controls if notifications should be cleared first. True is the default
#}
{% set clear = clear|default(true) %}
{% if messages %}
<div x-init="() => {
{% if clear %}
$notifications.clear()
{% endif %}
{% for message in messages %}
$notifications.{{ message.tags }}('{{ message }}')
{% endfor %}
}"></div>
{% endif %}
{% endmacro %}

View File

@@ -12,18 +12,6 @@
{% endif %}
{% endblock %}
{% block metatags %}
{% if page %}
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
<meta property="og:title" content="{{ page.get_display_name() }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% else %}
{{ super() }}
{% endif %}
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >

View File

@@ -5,12 +5,16 @@
{% endblock %}
{% block content %}
{% if page_list %}
<h3>{% trans %}Page list{% endtrans %}</h3>
<ul>
{% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.display_name }}</a></li>
<li><a href="{{ p.get_absolute_url() }}">{{ p.get_display_name() }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no page in this website.{% endtrans %}
{% endif %}
{% endblock %}

View File

@@ -30,11 +30,7 @@
- {{ purchase.date|localtime|time(DATETIME_FORMAT) }}
</td>
<td>{{ purchase.counter }}</td>
{% if not purchase.seller %}
<td>{% trans %}Deleted user{% endtrans %}</td>
{% else %}
<td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td>
{% endif %}
<td>{{ purchase.label }}</td>
<td>{{ purchase.quantity }}</td>
<td>{{ purchase.quantity * purchase.unit_price }} €</td>

View File

@@ -1,4 +1,3 @@
{% spaceless %}
{% for js in statics.js %}
<script-once type="module" src="{{ js }}"></script-once>
{% endfor %}
@@ -22,4 +21,3 @@
<slot style="display:none" name="initial">{{ initial }}</slot>
{% endif %}
</{{ component }}>
{% endspaceless %}

View File

@@ -421,16 +421,18 @@ class TestUserIsInGroup(TestCase):
# clear the cached property `User.cached_groups`
self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is in the group
with self.assertNumQueries(1):
with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group)
self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is not in the group
with self.assertNumQueries(1):
with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_not_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_not_in.id)

View File

@@ -46,7 +46,7 @@ class TestFetchFamilyApi(TestCase):
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == 401
assert response.status_code == 403
self.client.force_login(baker.make(User)) # unsubscribed user
response = self.client.get(

View File

@@ -5,6 +5,7 @@ from typing import Callable
from uuid import uuid4
import pytest
from django.conf import settings
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase
@@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Picture
from sith import settings
@pytest.mark.django_db
@@ -30,24 +31,19 @@ class TestImageAccess:
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
],
)
def test_sas_image_access(self, user_factory: Callable[[], User]):
"""Test that only authorized users can access the sas image."""
user = user_factory()
picture: SithFile = baker.make(
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
)
assert picture.is_owned_by(user)
picture = picture_recipe.make()
assert user.can_edit(picture)
def test_sas_image_access_owner(self):
"""Test that the owner of the image can access it."""
user = baker.make(User)
picture: Picture = baker.make(Picture, owner=user)
assert picture.is_owned_by(user)
picture = picture_recipe.make(owner=user)
assert user.can_edit(picture)
@pytest.mark.parametrize(
"user_factory",
@@ -63,7 +59,41 @@ class TestImageAccess:
user = user_factory()
owner = baker.make(User)
picture: Picture = baker.make(Picture, owner=owner)
assert not picture.is_owned_by(user)
assert not user.can_edit(picture)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:
@@ -269,7 +299,7 @@ def test_apply_rights_recursively():
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
401,
403,
),
(
lambda: baker.make(User),

View File

@@ -1,58 +0,0 @@
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from core.baker_recipes import board_user, subscriber_user
from core.models import AnonymousUser, Page, User
from sith.settings import SITH_GROUP_OLD_SUBSCRIBERS_ID, SITH_GROUP_SUBSCRIBERS_ID
@pytest.mark.django_db
def test_edit_page(client: Client):
user = board_user.make()
page = baker.prepare(Page)
page.save(force_lock=True)
page.view_groups.add(user.groups.first())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": "Hello World"})
assertRedirects(res, reverse("core:page", kwargs={"page_name": page._full_name}))
revision = page.revisions.last()
assert revision.content == "Hello World"
@pytest.mark.django_db
def test_viewable_by():
# remove existing pages to prevent side effect
Page.objects.all().delete()
view_groups = [
[settings.SITH_GROUP_PUBLIC_ID],
[settings.SITH_GROUP_PUBLIC_ID, SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID, SITH_GROUP_OLD_SUBSCRIBERS_ID],
[],
]
pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True)
for page, groups in zip(pages, view_groups, strict=True):
page.view_groups.set(groups)
viewable = Page.objects.viewable_by(AnonymousUser()).values_list("id", flat=True)
assert set(viewable) == {pages[0].id, pages[1].id}
subscriber = subscriber_user.make()
viewable = Page.objects.viewable_by(subscriber).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages[0:4]}
root_user = baker.make(
User, user_permissions=[Permission.objects.get(codename="view_page")]
)
viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages}

View File

@@ -20,9 +20,9 @@ from core.baker_recipes import (
)
from core.models import Group, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase):
@@ -30,6 +30,7 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
Picture.objects.all().delete() # same for pictures
User.objects.all().delete()
user_recipe = Recipe(
User,
@@ -130,31 +131,6 @@ def test_user_account_not_found(client: Client):
assert res.status_code == 404
@pytest.mark.django_db
def test_is_deleted_barman_shown_as_deleted(client: Client):
customer = baker.make(Customer)
date = now()
sale_recipe.make(
seller=iter([None, baker.make(User)]),
customer=customer,
date=date,
_quantity=2,
_bulk_create=True,
)
client.force_login(customer.user)
res = client.get(
reverse(
"core:user_account_detail",
kwargs={
"user_id": customer.user.id,
"year": date.year,
"month": date.month,
},
)
)
assert res.status_code == 200
class TestFilterInactive(TestCase):
@classmethod
def setUpTestData(cls):

View File

@@ -12,18 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from dataclasses import dataclass
from datetime import date, timedelta
# Image utils
from io import BytesIO
from typing import Final
from typing import Any, Final, Unpack
import PIL
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.db import models
from django.forms import BaseForm
from django.http import Http404, HttpRequest
from django.shortcuts import get_list_or_404
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.utils.timezone import localdate
from PIL import ExifTags
from PIL.Image import Image, Resampling
@@ -42,6 +47,21 @@ to generate a dummy image that is considered valid nonetheless
"""
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester.
@@ -195,3 +215,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip
return None
Filterable = models.Model | models.QuerySet | models.Manager
ListFilter = dict[str, list | tuple | set]
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
"""Use filter() to return a list of objects from a list of unique keys (like ids)
or raises Http404 if the list has not the same length as the given one.
Work like `get_object_or_404()` but for lists of objects, with some caveats :
- The filter must be a list, a tuple or a set.
- There can't be more than exactly one filter.
- There must be no duplicate in the filter.
- The filter should consist in unique keys (like ids), or it could fail randomly.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the filter() query.
Raises:
Http404: If the list is empty or doesn't have as many elements as the keys list.
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
ValueError: If more than one filter is passed.
TypeError: If the given filter is not a list, a tuple or a set.
Examples:
Get all the products with ids 1, 2, 3: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
Don't work with duplicate ids: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
# Raises Http404: "The list of keys must contain no duplicates."
"""
if len(kwargs) > 1:
raise ValueError("get_list_exact_or_404() only accepts one filter.")
key, list_filter = next(iter(kwargs.items()))
if not isinstance(list_filter, (list, tuple, set)):
raise TypeError(
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
)
if len(list_filter) != len(set(list_filter)):
raise ValueError("The list of keys must contain no duplicates.")
kwargs = {key: list_filter}
obj_list = get_list_or_404(klass, **kwargs)
if len(obj_list) != len(list_filter):
raise Http404(
"The given list of keys doesn't match the number of objects found."
f"Expected {len(list_filter)} items, got {len(obj_list)}."
)
return obj_list

View File

@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
class FileModerationView(AllowFragment, ListView):
model = SithFile
template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
queryset = SithFile.objects.filter(is_moderated=False)
ordering = "id"
paginate_by = 100

View File

@@ -115,7 +115,7 @@ class SelectUser(TextInput):
def validate_future_timestamp(value: date | datetime):
if value <= now():
raise ValidationError(_("Ensure this timestamp is set in the future"))
raise ValueError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField):

View File

@@ -2,6 +2,7 @@ import copy
import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string
@@ -40,6 +41,36 @@ class TabedViewMixin(View):
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""

View File

@@ -12,10 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model
from django.forms.models import modelform_factory
@@ -43,26 +40,10 @@ class CanEditPagePropMixin(CanEditPropMixin):
return res
class PageListView(ListView):
class PageListView(CanViewMixin, ListView):
model = Page
template_name = "core/page_list.jinja"
def get_queryset(self):
return (
Page.objects.viewable_by(self.request.user)
.annotate(
display_name=Coalesce(
Subquery(
PageRev.objects.filter(page=OuterRef("id"))
.order_by("-date")
.values("title")[:1]
),
F("name"),
)
)
.select_related("parent")
)
class PageView(CanViewMixin, DetailView):
model = Page
@@ -186,7 +167,7 @@ class PageEditViewBase(CanEditMixin, UpdateView):
)
template_name = "core/pagerev_edit.jinja"
def get_object(self, *args, **kwargs):
def get_object(self):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self._get_revision()

View File

@@ -65,7 +65,7 @@ from core.views.forms import (
UserGroupsForm,
UserProfileForm,
)
from core.views.mixins import TabedViewMixin, UseFragmentsMixin
from core.views.mixins import QuickNotifMixin, TabedViewMixin, UseFragmentsMixin
from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice
from subscription.models import Subscription
@@ -564,7 +564,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "groups"
class UserToolsView(LoginRequiredMixin, UserTabsMixin, TemplateView):
class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView):
"""Displays the logged user's tools."""
template_name = "core/user_tools.jinja"

View File

@@ -22,7 +22,6 @@ from counter.models import (
Counter,
Customer,
Eticket,
InvoiceCall,
Permanency,
Product,
ProductType,
@@ -161,11 +160,3 @@ class CashRegisterSummaryAdmin(SearchModelAdmin):
class EticketAdmin(SearchModelAdmin):
list_display = ("product", "event_date", "event_title")
search_fields = ("product__name", "event_title")
@admin.register(InvoiceCall)
class InvoiceCallAdmin(SearchModelAdmin):
list_display = ("club", "month", "is_validated")
search_fields = ("club__name",)
list_filter = (("club", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "month"

View File

@@ -64,7 +64,7 @@ class CounterController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimplifiedCounterSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -77,7 +77,7 @@ class ProductController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleProductSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -117,7 +117,7 @@ class ProductTypeController(ControllerBase):
def fetch_all(self):
return ProductType.objects.order_by("order")
@route.patch("/{type_id}/move", url_name="reorder_product_type")
@route.patch("/{type_id}/move")
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
"""Change the order of a product type.

View File

@@ -1,26 +1,13 @@
import json
import math
import uuid
from datetime import date
from dateutil.relativedelta import relativedelta
from django import forms
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.utils.timezone import now
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User
from core.views.forms import (
FutureDateTimeField,
NFCTextInput,
SelectDate,
SelectDateTime,
)
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
@@ -32,14 +19,10 @@ from counter.models import (
Counter,
Customer,
Eticket,
InvoiceCall,
Product,
Refilling,
ReturnableProduct,
ScheduledProductAction,
Selling,
StudentCard,
get_product_actions,
)
from counter.widgets.ajax_select import (
AutoCompleteSelectMultipleCounter,
@@ -175,101 +158,7 @@ class CounterEditForm(forms.ModelForm):
}
class ScheduledProductActionForm(forms.ModelForm):
"""Form for automatic product archiving.
The `save` method will update or create tasks using celery-beat.
"""
required_css_class = "required"
prefix = "scheduled"
class Meta:
model = ScheduledProductAction
fields = ["task"]
widgets = {"task": forms.RadioSelect(choices=get_product_actions)}
labels = {"task": _("Action")}
help_texts = {"task": ""}
trigger_at = FutureDateTimeField(
label=_("Date and time of action"), widget=SelectDateTime
)
counters = forms.ModelMultipleChoiceField(
label=_("New counters"),
help_text=_("The selected counters will replace the current ones"),
required=False,
widget=AutoCompleteSelectMultipleCounter,
queryset=Counter.objects.all(),
)
def __init__(self, *args, product: Product, **kwargs):
self.product = product
super().__init__(*args, **kwargs)
if not self.instance._state.adding:
self.fields["trigger_at"].initial = self.instance.clocked.clocked_time
self.fields["counters"].initial = json.loads(self.instance.kwargs).get(
"counters"
)
def clean(self):
if not self.changed_data or "trigger_at" in self.errors:
return super().clean()
if "trigger_at" in self.changed_data:
if not self.instance.clocked_id:
self.instance.clocked = ClockedSchedule(
clocked_time=self.cleaned_data["trigger_at"]
)
else:
self.instance.clocked.clocked_time = self.cleaned_data["trigger_at"]
self.instance.clocked.save()
task_kwargs = {"product_id": self.product.id}
if (
self.cleaned_data["task"] == "counter.tasks.change_counters"
and "counters" in self.changed_data
):
task_kwargs["counters"] = [c.id for c in self.cleaned_data["counters"]]
self.instance.product = self.product
self.instance.kwargs = json.dumps(task_kwargs)
self.instance.name = (
f"{self.cleaned_data['task']} - {self.product} - {uuid.uuid4()}"
)
return super().clean()
class BaseScheduledProductActionFormSet(BaseModelFormSet):
def __init__(self, *args, product: Product, **kwargs):
if product.id:
queryset = (
product.scheduled_actions.filter(
enabled=True, clocked__clocked_time__gt=now()
)
.order_by("clocked__clocked_time")
.select_related("clocked")
)
else:
queryset = ScheduledProductAction.objects.none()
form_kwargs = {"product": product}
super().__init__(*args, queryset=queryset, form_kwargs=form_kwargs, **kwargs)
def delete_existing(self, obj: ScheduledProductAction, commit: bool = True): # noqa FBT001
clocked = obj.clocked
super().delete_existing(obj, commit=commit)
if commit:
clocked.delete()
ScheduledProductActionFormSet = forms.modelformset_factory(
ScheduledProductAction,
ScheduledProductActionForm,
formset=BaseScheduledProductActionFormSet,
absolute_max=None,
can_delete=True,
can_delete_extra=False,
extra=2,
)
class ProductForm(forms.ModelForm):
class ProductEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
@@ -310,21 +199,22 @@ class ProductForm(forms.ModelForm):
queryset=Counter.objects.all(),
)
def __init__(self, *args, instance=None, **kwargs):
super().__init__(*args, instance=instance, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all()
self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs
)
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"])
self.action_formset.save()
if self.fields["counters"].initial:
# Remove the product from all counter it was added to
# It will then only be added to selected counters
for counter in self.fields["counters"].initial:
counter.products.remove(self.instance)
counter.save()
for counter in self.cleaned_data["counters"]:
counter.products.add(self.instance)
counter.save()
return ret
@@ -376,7 +266,7 @@ class CloseCustomerAccountForm(forms.Form):
)
class BasketProductForm(forms.Form):
class ProductForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True)
id = forms.IntegerField(min_value=0, required=True)
@@ -481,50 +371,5 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory(
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
self.clubs = list(
Club.objects.filter(
Exists(
Selling.objects.filter(
club=OuterRef("pk"),
date__gte=month,
date__lte=month + relativedelta(months=1),
)
)
).annotate(
validated_invoice=Exists(
InvoiceCall.objects.filter(
club=OuterRef("pk"), month=month, is_validated=True
)
)
)
)
self.fields = {
str(club.id): forms.BooleanField(
required=False, initial=club.validated_invoice
)
for club in self.clubs
}
def save(self):
invoice_calls = [
InvoiceCall(
month=self.month,
club_id=club.id,
is_validated=self.cleaned_data.get(str(club.id), False),
)
for club in self.clubs
]
InvoiceCall.objects.bulk_create(
invoice_calls,
update_conflicts=True,
update_fields=["is_validated"],
unique_fields=["month", "club"],
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)

View File

@@ -58,7 +58,7 @@ class Migration(migrations.Migration):
migrations.AddConstraint(
model_name="returnableproduct",
constraint=models.CheckConstraint(
condition=models.Q(
check=models.Q(
("product", models.F("returned_product")), _negated=True
),
name="returnableproduct_product_different_from_returned",

View File

@@ -1,40 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-14 11:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0031_alter_counter_options"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.CreateModel(
name="ScheduledProductAction",
fields=[
(
"periodictask_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="django_celery_beat.periodictask",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scheduled_actions",
to="counter.product",
),
),
],
options={"verbose_name": "Product scheduled action"},
bases=("django_celery_beat.periodictask",),
),
]

View File

@@ -1,51 +0,0 @@
# Generated by Django 5.2.3 on 2025-10-15 21:54
import django.db.models.deletion
from django.db import migrations, models
import counter.models
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("counter", "0032_scheduledproductaction"),
]
operations = [
migrations.CreateModel(
name="InvoiceCall",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"is_validated",
models.BooleanField(default=False, verbose_name="is validated"),
),
("month", counter.models.MonthField(verbose_name="invoice date")),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="club.club"
),
),
],
options={
"verbose_name": "Invoice call",
"verbose_name_plural": "Invoice calls",
"constraints": [
models.UniqueConstraint(
fields=("club", "month"),
name="counter_invoicecall_unique_club_month",
)
],
},
),
]

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