mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-30 14:19:35 +00:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc980113a | ||
|
|
83e8001eac | ||
|
|
52a70054f7 | ||
|
|
143a9849cd | ||
|
|
01edf1f688 | ||
|
|
366551559d | ||
|
|
59cdebbf4f | ||
|
|
f5a5782bac | ||
|
|
f11ac5d3be | ||
|
|
c6f518fffb | ||
|
|
c5015f4448 | ||
|
|
9bc6518d95 | ||
|
|
c66af5eaff | ||
|
|
c188dd0c18 | ||
|
|
4f5af29d43 | ||
|
|
80f1a7e574 | ||
|
|
7548c76d5e | ||
|
|
d7e1cdafde | ||
|
|
93b6238c75 | ||
|
|
91865fe7a1 | ||
|
|
1e189f7b2b | ||
|
|
1798406dde | ||
|
|
20fc7d587a | ||
|
|
94b108d288 | ||
|
|
059d5210ac | ||
|
|
562f75f410 | ||
|
|
32c30da0fc | ||
|
|
d3f3b18660 | ||
|
|
e50b1fd71e | ||
|
|
78376eaca4 | ||
|
|
8d56c72054 | ||
|
|
a794f02a8d | ||
|
|
cb0a2f866d | ||
|
|
4b4c5a73b6 | ||
|
|
b37b98b932 | ||
|
|
ed888834d6 | ||
|
|
0657888076 | ||
|
|
3f135777cd | ||
|
|
73ebe9c674 | ||
|
|
816cbe3a2c | ||
|
|
a33a4da143 | ||
|
|
2c77ed3c54 | ||
|
|
3312a6214e | ||
|
|
87533a7327 | ||
|
|
2db46e3958 | ||
|
|
f2469873fd | ||
|
|
68c05b5b1d | ||
|
|
2d492e0157 | ||
|
|
67cfcb66b6 | ||
|
|
b560c6860b | ||
|
|
15c5a37c99 | ||
|
|
07648f596e | ||
|
|
0c6594b3cd | ||
|
|
22b510e25b | ||
|
|
c9137a2a67 | ||
|
|
9f4b76dc1a | ||
|
|
ffc509c1a1 | ||
|
|
c2d76d025e | ||
|
|
80f0517bf8 | ||
|
|
9924dc05d0 | ||
|
|
614b870133 | ||
|
|
8437728251 | ||
|
|
8d3d5630c8 | ||
|
|
45cca918f1 | ||
|
|
f211d0ff85 | ||
|
|
0bd557ae73 | ||
|
|
3deefbe847 | ||
|
|
b2707b7862 | ||
|
|
7a0f4217cc | ||
|
|
f067c60a8c | ||
|
|
5bea3f14e8 | ||
|
|
1efbf93f62 | ||
|
|
a84224c6ff | ||
|
|
6ef0166540 | ||
|
|
e3a9e23d6a | ||
|
|
d6ab0e17f8 | ||
|
|
225b8cced9 | ||
|
|
f271b9d1df | ||
|
|
e39a73ae32 | ||
|
|
ad02026d63 | ||
|
|
a5440e3166 | ||
|
|
796ef72fa1 | ||
|
|
44f9965557 | ||
|
|
37871b386a | ||
|
|
c8f4d12fc4 | ||
|
|
63764f54ba | ||
|
|
2d8f56f1f7 | ||
|
|
809df909fd | ||
|
|
ef1ff862a8 | ||
|
|
19f0f23a05 | ||
|
|
6f701a9579 | ||
|
|
b607c86ab7 | ||
|
|
0c6a96a7ab | ||
|
|
c8813f708a | ||
|
|
03514e4d97 | ||
|
|
4e3ed3212e | ||
|
|
5b02bc2e3b |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.0
|
||||
rev: v0.17.2
|
||||
hooks:
|
||||
- id: ruff-check # just check the code, and print the errors
|
||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
rev: v0.6.1
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@2.5.1"]
|
||||
additional_dependencies: ["@biomejs/biome@2.6.3"]
|
||||
- repo: https://github.com/rtts/djhtml
|
||||
rev: 3.0.11
|
||||
hooks:
|
||||
|
||||
@@ -21,6 +21,15 @@ class ApiClientAdmin(admin.ModelAdmin):
|
||||
"owner__nick_name",
|
||||
)
|
||||
autocomplete_fields = ("owner", "groups", "client_permissions")
|
||||
readonly_fields = ("hmac_key",)
|
||||
actions = ("reset_hmac_key",)
|
||||
|
||||
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
|
||||
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
|
||||
objs = list(queryset)
|
||||
for obj in objs:
|
||||
obj.reset_hmac(commit=False)
|
||||
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
|
||||
|
||||
|
||||
@admin.register(ApiKey)
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.schemas import ApiClientSchema
|
||||
|
||||
|
||||
@api_controller("/client")
|
||||
class ApiClientController(ControllerBase):
|
||||
@route.get(
|
||||
"/me",
|
||||
auth=[ApiKeyAuth()],
|
||||
response=ApiClientSchema,
|
||||
url_name="api-client-infos",
|
||||
)
|
||||
def get_client_info(self):
|
||||
return self.context.request.auth
|
||||
@@ -0,0 +1,35 @@
|
||||
from django import forms
|
||||
from django.forms import HiddenInput
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ThirdPartyAuthForm(forms.Form):
|
||||
"""Form to complete to authenticate on the sith from a third-party app.
|
||||
|
||||
For the form to be valid, the user approve the EULA (french: CGU)
|
||||
and give its username from the third-party app.
|
||||
"""
|
||||
|
||||
cgu_accepted = forms.BooleanField(
|
||||
required=True,
|
||||
label=_("I have read and I accept the terms and conditions of use"),
|
||||
error_messages={
|
||||
"required": _("You must approve the terms and conditions of use.")
|
||||
},
|
||||
)
|
||||
is_username_valid = forms.BooleanField(
|
||||
required=True,
|
||||
error_messages={"required": _("You must confirm that this is your username.")},
|
||||
)
|
||||
client_id = forms.IntegerField(widget=HiddenInput())
|
||||
third_party_app = forms.CharField(widget=HiddenInput())
|
||||
privacy_link = forms.URLField(widget=HiddenInput())
|
||||
username = forms.CharField(widget=HiddenInput())
|
||||
callback_url = forms.URLField(widget=HiddenInput())
|
||||
signature = forms.CharField(widget=HiddenInput())
|
||||
|
||||
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
|
||||
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
|
||||
self.fields["is_username_valid"].label = _(
|
||||
"I confirm that %(username)s is my username on %(app)s"
|
||||
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.3 on 2025-10-26 10:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import api.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("api", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="apiclient",
|
||||
name="hmac_key",
|
||||
field=models.CharField(
|
||||
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
|
||||
),
|
||||
),
|
||||
]
|
||||
+33
-22
@@ -1,13 +1,20 @@
|
||||
import secrets
|
||||
from typing import Iterable
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from core.models import Group, User
|
||||
|
||||
|
||||
def get_hmac_key():
|
||||
return secrets.token_hex(64)
|
||||
|
||||
|
||||
class ApiClient(models.Model):
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
owner = models.ForeignKey(
|
||||
@@ -26,11 +33,10 @@ class ApiClient(models.Model):
|
||||
help_text=_("Specific permissions for this api client."),
|
||||
related_name="clients",
|
||||
)
|
||||
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
_perm_cache: set[str] | None = None
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("api client")
|
||||
verbose_name_plural = _("api clients")
|
||||
@@ -38,33 +44,38 @@ class ApiClient(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@cached_property
|
||||
def all_permissions(self) -> set[str]:
|
||||
permissions = (
|
||||
Permission.objects.filter(
|
||||
Q(group__group__in=self.groups.all()) | Q(clients=self)
|
||||
)
|
||||
.values_list("content_type__app_label", "codename")
|
||||
.order_by()
|
||||
)
|
||||
return {f"{content_type}.{name}" for content_type, name in permissions}
|
||||
|
||||
def has_perm(self, perm: str):
|
||||
"""Return True if the client has the specified permission."""
|
||||
return perm in self.all_permissions
|
||||
|
||||
if self._perm_cache is None:
|
||||
group_permissions = (
|
||||
Permission.objects.filter(group__group__in=self.groups.all())
|
||||
.values_list("content_type__app_label", "codename")
|
||||
.order_by()
|
||||
)
|
||||
client_permissions = self.client_permissions.values_list(
|
||||
"content_type__app_label", "codename"
|
||||
).order_by()
|
||||
self._perm_cache = {
|
||||
f"{content_type}.{name}"
|
||||
for content_type, name in (*group_permissions, *client_permissions)
|
||||
}
|
||||
return perm in self._perm_cache
|
||||
|
||||
def has_perms(self, perm_list):
|
||||
"""
|
||||
Return True if the client has each of the specified permissions. If
|
||||
object is passed, check if the client has all required perms for it.
|
||||
"""
|
||||
def has_perms(self, perm_list: Iterable[str]) -> bool:
|
||||
"""Return True if the client has each of the specified permissions."""
|
||||
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
|
||||
raise ValueError("perm_list must be an iterable of permissions.")
|
||||
return all(self.has_perm(perm) for perm in perm_list)
|
||||
|
||||
def reset_hmac(self, *, commit: bool = True) -> str:
|
||||
"""Reset and return the HMAC key for this client.
|
||||
|
||||
Args:
|
||||
commit: if True (the default), persist the new hmac in db.
|
||||
"""
|
||||
self.hmac_key = get_hmac_key()
|
||||
if commit:
|
||||
self.save()
|
||||
return self.hmac_key
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
PREFIX_LENGTH = 5
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import Field, HttpUrl
|
||||
|
||||
from api.models import ApiClient
|
||||
from core.schemas import SimpleUserSchema
|
||||
|
||||
|
||||
class ApiClientSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ApiClient
|
||||
fields = ["id", "name"]
|
||||
|
||||
owner: SimpleUserSchema
|
||||
permissions: list[str] = Field(alias="all_permissions")
|
||||
|
||||
|
||||
class ThirdPartyAuthParamsSchema(Schema):
|
||||
client_id: int
|
||||
third_party_app: str
|
||||
privacy_link: HttpUrl
|
||||
username: str
|
||||
callback_url: HttpUrl
|
||||
signature: str
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
|
||||
<p>
|
||||
{% trans trimmed app=third_party_app %}
|
||||
By ticking this box and clicking on the send button, you
|
||||
acknowledge and agree to provide {{ app }} with your
|
||||
first name, last name, nickname and any other information
|
||||
that was the third party app was explicitly authorized to fetch
|
||||
and that it must have acknowledged to you, in a complete and accurate manner.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<p class="margin-bottom">
|
||||
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
|
||||
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
|
||||
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
|
||||
applies as soon as the form is submitted.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
|
||||
<br>
|
||||
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
|
||||
<div class="row margin-bottom">
|
||||
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
|
||||
</div>
|
||||
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
|
||||
<input type="submit" class="btn btn-blue">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.http import HttpRequest
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from api.admin import ApiClientAdmin
|
||||
from api.models import ApiClient
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_hmac_action():
|
||||
client_admin = ApiClientAdmin(ApiClient, AdminSite())
|
||||
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
|
||||
old_hmac_keys = [c.hmac_key for c in api_clients]
|
||||
with assertNumQueries(2):
|
||||
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
|
||||
client_admin.reset_hmac_key(HttpRequest(), qs)
|
||||
for c in api_clients:
|
||||
c.refresh_from_db()
|
||||
assert api_clients[0].hmac_key == old_hmac_keys[0]
|
||||
assert api_clients[1].hmac_key == old_hmac_keys[1]
|
||||
assert api_clients[2].hmac_key != old_hmac_keys[2]
|
||||
assert api_clients[3].hmac_key != old_hmac_keys[3]
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from api.hashers import generate_key
|
||||
from api.models import ApiClient, ApiKey
|
||||
from api.schemas import ApiClientSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.test import Client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_api_client_controller(client: Client):
|
||||
key, hashed = generate_key()
|
||||
api_client = baker.make(ApiClient)
|
||||
baker.make(ApiKey, client=api_client, hashed_key=hashed)
|
||||
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from model_bakery import baker
|
||||
|
||||
from api.models import ApiClient
|
||||
from core.models import Group
|
||||
|
||||
|
||||
class TestClientPermissions(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.api_client = baker.make(ApiClient)
|
||||
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
|
||||
cls.api_client.groups.set(
|
||||
[
|
||||
baker.make(Group, permissions=cls.perms[0:3]),
|
||||
baker.make(Group, permissions=cls.perms[3:5]),
|
||||
]
|
||||
)
|
||||
cls.api_client.client_permissions.set(
|
||||
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
|
||||
)
|
||||
|
||||
def test_all_permissions(self):
|
||||
assert self.api_client.all_permissions == {
|
||||
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
|
||||
}
|
||||
|
||||
def test_has_perm(self):
|
||||
assert self.api_client.has_perm(
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
|
||||
)
|
||||
assert not self.api_client.has_perm(
|
||||
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
|
||||
)
|
||||
|
||||
def test_has_perms(self):
|
||||
assert self.api_client.has_perms(
|
||||
[
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
|
||||
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
|
||||
]
|
||||
)
|
||||
assert not self.api_client.has_perms(
|
||||
[
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
|
||||
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_hmac_key():
|
||||
client = baker.make(ApiClient)
|
||||
original_key = client.hmac_key
|
||||
client.reset_hmac(commit=True)
|
||||
assert len(client.hmac_key) == len(original_key)
|
||||
assert client.hmac_key != original_key
|
||||
@@ -0,0 +1,140 @@
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.contrib.messages import Message, get_messages
|
||||
from django.db.models import Max
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from api.models import ApiClient, get_hmac_key
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.schemas import UserProfileSchema
|
||||
from core.utils import hmac_hexdigest
|
||||
|
||||
|
||||
def mocked_post(*, ok: bool):
|
||||
class MockedResponse(Mock):
|
||||
@property
|
||||
def ok(self):
|
||||
return ok
|
||||
|
||||
def mocked():
|
||||
return MockedResponse()
|
||||
|
||||
return mocked
|
||||
|
||||
|
||||
class TestThirdPartyAuth(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = subscriber_user.make()
|
||||
cls.api_client = baker.make(ApiClient)
|
||||
|
||||
def setUp(self):
|
||||
self.query = {
|
||||
"client_id": self.api_client.id,
|
||||
"third_party_app": "app",
|
||||
"privacy_link": "https://foobar.fr/",
|
||||
"username": "bibou",
|
||||
"callback_url": "https://callback.fr/",
|
||||
}
|
||||
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
|
||||
self.callback_data = {
|
||||
"user": UserProfileSchema.from_orm(self.user).model_dump()
|
||||
}
|
||||
self.callback_data["signature"] = hmac_hexdigest(
|
||||
self.api_client.hmac_key, self.callback_data["user"]
|
||||
)
|
||||
self.url = reverse("api-link:third-party-auth", query=self.query)
|
||||
|
||||
def test_auth_ok(self):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
|
||||
res = self.client.post(
|
||||
self.url,
|
||||
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
|
||||
)
|
||||
mocked.assert_called_once_with(
|
||||
self.query["callback_url"], json=self.callback_data
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
|
||||
)
|
||||
|
||||
def test_callback_error(self):
|
||||
"""Test that the user see the failure page if the callback request failed."""
|
||||
self.client.force_login(self.user)
|
||||
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
|
||||
res = self.client.post(
|
||||
self.url,
|
||||
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
|
||||
)
|
||||
mocked.assert_called_once_with(
|
||||
self.query["callback_url"], json=self.callback_data
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
|
||||
)
|
||||
|
||||
def test_wrong_signature(self):
|
||||
"""Test that a 403 is raised if the signature of the query is wrong."""
|
||||
self.client.force_login(subscriber_user.make())
|
||||
new_key = get_hmac_key()
|
||||
del self.query["signature"]
|
||||
self.query["signature"] = hmac_hexdigest(new_key, self.query)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert list(get_messages(res.wsgi_request)) == [
|
||||
Message(
|
||||
level=40,
|
||||
message=(
|
||||
"La signature est incorrecte. "
|
||||
"Nous ne pouvons pas garantir l'authenticité de la requête."
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
res = self.client.post(self.url, data=self.query)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_cgu_not_accepted(self):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
res = self.client.post(self.url, data=self.query)
|
||||
assert res.status_code == 200 # no redirect means invalid form
|
||||
res = self.client.post(
|
||||
self.url,
|
||||
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_invalid_client(self):
|
||||
self.client.force_login(self.user)
|
||||
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert list(get_messages(res.wsgi_request)) == [
|
||||
Message(
|
||||
level=40,
|
||||
message="Les données fournies pour l'authentification sont incorrectes.",
|
||||
)
|
||||
]
|
||||
|
||||
def test_missing_parameter(self):
|
||||
self.client.force_login(self.user)
|
||||
del self.query["username"]
|
||||
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert list(get_messages(res.wsgi_request)) == [
|
||||
Message(
|
||||
level=40,
|
||||
message="Les données fournies pour l'authentification sont incorrectes.",
|
||||
)
|
||||
]
|
||||
res = self.client.post(self.url, data=self.query)
|
||||
assert res.status_code == 200
|
||||
+15
@@ -1,6 +1,10 @@
|
||||
from django.urls import path, register_converter
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import NinjaExtraAPI
|
||||
|
||||
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
|
||||
from core.converters import ResultConverter
|
||||
|
||||
api = NinjaExtraAPI(
|
||||
title="PICON",
|
||||
description="Portail Interactif de Communication avec les Outils Numériques",
|
||||
@@ -9,3 +13,14 @@ api = NinjaExtraAPI(
|
||||
auth=[SessionAuth()],
|
||||
)
|
||||
api.auto_discover_controllers()
|
||||
|
||||
register_converter(ResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
|
||||
path(
|
||||
"auth/<res:result>/",
|
||||
ThirdPartyAuthResultView.as_view(),
|
||||
name="third-party-auth-result",
|
||||
),
|
||||
]
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import hmac
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pydantic
|
||||
import requests
|
||||
import sentry_sdk
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView, TemplateView
|
||||
from ninja_extra.shortcuts import get_object_or_none
|
||||
|
||||
from api.forms import ThirdPartyAuthForm
|
||||
from api.models import ApiClient
|
||||
from api.schemas import ThirdPartyAuthParamsSchema
|
||||
from core.models import Page
|
||||
from core.schemas import UserProfileSchema
|
||||
from core.utils import hmac_hexdigest
|
||||
|
||||
|
||||
class ThirdPartyAuthView(AccessMixin, FormView):
|
||||
form_class = ThirdPartyAuthForm
|
||||
template_name = "api/third_party/auth.jinja"
|
||||
success_url = reverse_lazy("core:index")
|
||||
|
||||
def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
|
||||
"""Parse and check the authentication parameters.
|
||||
|
||||
If parsing fails, messages will be created using the django message
|
||||
infrastructure.
|
||||
|
||||
Returns:
|
||||
The parses parameters, or None if the parsing failed.
|
||||
"""
|
||||
# This is here rather than in ThirdPartyAuthForm because
|
||||
# the given parameters and their signature are checked during both
|
||||
# POST (for obvious reasons) and GET (in order not to make
|
||||
# the user fill a form just to get an error he won't understand)
|
||||
params = self.request.GET if self.request.method == "GET" else self.request.POST
|
||||
params = {key: unquote(val) for key, val in params.dict().items()}
|
||||
try:
|
||||
params = ThirdPartyAuthParamsSchema(**params)
|
||||
except pydantic.ValidationError:
|
||||
messages.error(
|
||||
self.request, _("The data provided for authentication is incorrect")
|
||||
)
|
||||
return None
|
||||
client: ApiClient | None = get_object_or_none(ApiClient, id=params.client_id)
|
||||
if not client:
|
||||
messages.error(
|
||||
self.request, _("The data provided for authentication is incorrect")
|
||||
)
|
||||
return None
|
||||
if not hmac.compare_digest(
|
||||
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
|
||||
params.signature,
|
||||
):
|
||||
messages.error(
|
||||
self.request,
|
||||
_(
|
||||
"The signature is incorrect. "
|
||||
"We cannot ensure the provenance of the request."
|
||||
),
|
||||
)
|
||||
return None
|
||||
return params
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
if (params := self.parse_params()) is None:
|
||||
# if parameters parsing failed, shortcut the operation and display
|
||||
# an empty page with just the error messages.
|
||||
return render(request, "core/base.jinja")
|
||||
self.params = params
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"You are going to link your AE account and your %(app)s account. "
|
||||
"Continue only if this page was opened from %(app)s."
|
||||
)
|
||||
% {"app": self.params.third_party_app},
|
||||
)
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
return self.params.model_dump()
|
||||
|
||||
def form_valid(self, form):
|
||||
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
|
||||
user = UserProfileSchema.from_orm(self.request.user).model_dump()
|
||||
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
|
||||
try:
|
||||
ok = requests.post(form.cleaned_data["callback_url"], json=data).ok
|
||||
except requests.RequestException as e:
|
||||
sentry_sdk.capture_exception(e)
|
||||
ok = False
|
||||
self.success_url = reverse(
|
||||
"api-link:third-party-auth-result",
|
||||
kwargs={"result": "success" if ok else "failure"},
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"third_party_app": self.params.third_party_app,
|
||||
"third_party_cgu": self.params.privacy_link,
|
||||
"sith_cgu": Page.objects.get(_full_name=settings.SITH_CGU_PAGE),
|
||||
}
|
||||
|
||||
|
||||
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
|
||||
"""View that the user will see if its authentication on sith was successful.
|
||||
|
||||
This can show either a success or a failure message :
|
||||
- success : everything is good, the user is successfully authenticated
|
||||
and can close the page
|
||||
- failure : the authentication has been processed on the sith side,
|
||||
but the request to the callback url received an error.
|
||||
In such a case, there is nothing much we can do but to advice
|
||||
the user to contact the developers of the third-party app.
|
||||
"""
|
||||
|
||||
template_name = "core/base.jinja"
|
||||
success_message = _(
|
||||
"You have been successfully authenticated. You can now close this page."
|
||||
)
|
||||
error_message = _(
|
||||
"Your authentication on the AE website was successful, "
|
||||
"but an error happened during the interaction "
|
||||
"with the third-party application. "
|
||||
"Please contact the managers of the latter."
|
||||
)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if self.kwargs.get("result") == "success":
|
||||
messages.success(request, self.success_message)
|
||||
else:
|
||||
messages.error(request, self.error_message)
|
||||
return super().get(request, *args, **kwargs)
|
||||
+2
-113
@@ -31,20 +31,10 @@ from django.forms.models import ModelChoiceField, ModelChoiceIterator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import (
|
||||
Club,
|
||||
ClubLink,
|
||||
ClubRole,
|
||||
Mailing,
|
||||
MailingSubscription,
|
||||
Membership,
|
||||
)
|
||||
from club.models import Club, ClubLink, ClubRole, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDateTime
|
||||
from core.views.widgets.ajax_select import (
|
||||
AutoCompleteSelectMultipleUser,
|
||||
AutoCompleteSelectUser,
|
||||
)
|
||||
from core.views.widgets.ajax_select import AutoCompleteSelectUser
|
||||
from counter.models import Counter, Selling
|
||||
from counter.schemas import SaleFilterSchema
|
||||
|
||||
@@ -131,107 +121,6 @@ class ClubAdminEditForm(ClubEditForm):
|
||||
fields = ["name", "parent", "is_active", *ClubEditForm.Meta.fields]
|
||||
|
||||
|
||||
class MailingForm(forms.Form):
|
||||
"""Form handling mailing lists right."""
|
||||
|
||||
ACTION_NEW_MAILING = 1
|
||||
ACTION_NEW_SUBSCRIPTION = 2
|
||||
ACTION_REMOVE_SUBSCRIPTION = 3
|
||||
|
||||
subscription_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, club_id, user_id, mailings, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields["action"] = forms.TypedChoiceField(
|
||||
choices=(
|
||||
(self.ACTION_NEW_MAILING, _("New Mailing")),
|
||||
(self.ACTION_NEW_SUBSCRIPTION, _("Subscribe")),
|
||||
(self.ACTION_REMOVE_SUBSCRIPTION, _("Remove")),
|
||||
),
|
||||
coerce=int,
|
||||
label=_("Action"),
|
||||
initial=1,
|
||||
required=True,
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
|
||||
# Generate bulk removal forms, they are never required
|
||||
for mailing in mailings:
|
||||
self.fields["removal_" + str(mailing.id)] = forms.ModelMultipleChoiceField(
|
||||
mailing.subscriptions.all(),
|
||||
label=_("Remove"),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
)
|
||||
|
||||
# Include fields for handling mailing creation
|
||||
mailing_fields = ("email",)
|
||||
self.fields.update(forms.fields_for_model(Mailing, fields=mailing_fields))
|
||||
for field in mailing_fields:
|
||||
self.fields["mailing_" + field] = self.fields.pop(field)
|
||||
self.fields["mailing_" + field].required = False
|
||||
|
||||
# Include fields for handling subscription creation
|
||||
subscription_fields = ("mailing", "email")
|
||||
self.fields.update(
|
||||
forms.fields_for_model(MailingSubscription, fields=subscription_fields)
|
||||
)
|
||||
for field in subscription_fields:
|
||||
self.fields["subscription_" + field] = self.fields.pop(field)
|
||||
self.fields["subscription_" + field].required = False
|
||||
|
||||
self.fields["subscription_mailing"].queryset = Mailing.objects.filter(
|
||||
club__id=club_id, is_moderated=True
|
||||
)
|
||||
|
||||
def check_required(self, cleaned_data, field):
|
||||
"""If the given field doesn't exist or has no value, add a required error on it."""
|
||||
if not cleaned_data.get(field, None):
|
||||
self.add_error(field, _("This field is required"))
|
||||
|
||||
def clean_subscription_users(self):
|
||||
"""Convert given users into real users and check their validity."""
|
||||
cleaned_data = super().clean()
|
||||
users = []
|
||||
for user in cleaned_data["subscription_users"]:
|
||||
if not user.email:
|
||||
raise forms.ValidationError(
|
||||
_("One of the selected users doesn't have an email address"),
|
||||
code="invalid",
|
||||
)
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if "action" not in cleaned_data:
|
||||
# If there is no action provided, we can stop here
|
||||
raise forms.ValidationError(_("An action is required"), code="invalid")
|
||||
|
||||
if cleaned_data["action"] == self.ACTION_NEW_MAILING:
|
||||
self.check_required(cleaned_data, "mailing_email")
|
||||
|
||||
if cleaned_data["action"] == self.ACTION_NEW_SUBSCRIPTION:
|
||||
self.check_required(cleaned_data, "subscription_mailing")
|
||||
if not cleaned_data.get(
|
||||
"subscription_users", None
|
||||
) and not cleaned_data.get("subscription_email", None):
|
||||
raise forms.ValidationError(
|
||||
_("You must specify at least an user or an email address"),
|
||||
code="invalid",
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class SellingsForm(forms.Form):
|
||||
begin_date = forms.DateTimeField(
|
||||
label=_("Begin date"), widget=SelectDateTime, required=False
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-15 13:13
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("club", "0018_clubrole_linked_groups")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="mailingsubscription", unique_together=None
|
||||
),
|
||||
migrations.RemoveField(model_name="mailingsubscription", name="user"),
|
||||
migrations.DeleteModel(name="Mailing"),
|
||||
migrations.DeleteModel(name="MailingSubscription"),
|
||||
]
|
||||
+3
-167
@@ -28,8 +28,7 @@ from functools import reduce
|
||||
from typing import Iterable, Self
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import RegexValidator, validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import ProgrammingError, models, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
@@ -41,7 +40,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from ordered_model.models import OrderedModel
|
||||
|
||||
from core.fields import ResizedImageField
|
||||
from core.models import Group, Notification, Page, SithFile, User
|
||||
from core.models import Group, Page, SithFile, User
|
||||
|
||||
|
||||
class ClubQuerySet(models.QuerySet):
|
||||
@@ -238,7 +237,7 @@ class Club(models.Model):
|
||||
|
||||
def can_be_edited_by(self, user: User) -> bool:
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
return self.has_rights_in_club(user)
|
||||
return user.has_perm("club.change_club") or self.has_rights_in_club(user)
|
||||
|
||||
def can_roles_be_edited_by(self, user: User) -> bool:
|
||||
"""Return True if the given user can edit the roles of this club"""
|
||||
@@ -633,169 +632,6 @@ class Membership(models.Model):
|
||||
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
|
||||
|
||||
|
||||
class Mailing(models.Model):
|
||||
"""A Mailing list for a club.
|
||||
|
||||
Warning:
|
||||
Remember that mailing lists should be validated by UTBM.
|
||||
"""
|
||||
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
verbose_name=_("Club"),
|
||||
related_name="mailings",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
email = models.CharField(
|
||||
_("Email address"),
|
||||
unique=True,
|
||||
null=False,
|
||||
blank=False,
|
||||
max_length=256,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
validate_email.user_regex,
|
||||
_("Enter a valid address. Only the root of the address is needed."),
|
||||
)
|
||||
],
|
||||
)
|
||||
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
||||
moderator = models.ForeignKey(
|
||||
User,
|
||||
related_name="moderated_mailings",
|
||||
verbose_name=_("moderator"),
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return "%s - %s" % (self.club, self.email_full)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.is_moderated:
|
||||
unread_notif_subquery = Notification.objects.filter(
|
||||
user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
|
||||
)
|
||||
for user in User.objects.filter(
|
||||
~Exists(unread_notif_subquery),
|
||||
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
|
||||
):
|
||||
Notification(
|
||||
user=user,
|
||||
url=reverse("com:mailing_admin"),
|
||||
type="MAILING_MODERATION",
|
||||
).save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
if Mailing.objects.filter(email=self.email).exists():
|
||||
raise ValidationError(_("This mailing list already exists."))
|
||||
if self.can_moderate(self.moderator):
|
||||
self.is_moderated = True
|
||||
else:
|
||||
self.moderator = None
|
||||
super().clean()
|
||||
|
||||
@property
|
||||
def email_full(self):
|
||||
return self.email + "@" + settings.SITH_MAILING_DOMAIN
|
||||
|
||||
def can_moderate(self, user):
|
||||
return user.is_root or user.is_com_admin
|
||||
|
||||
def is_owned_by(self, user):
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return user.is_root or user.is_com_admin
|
||||
|
||||
def can_view(self, user):
|
||||
return self.club.has_rights_in_club(user)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
return self.club.has_rights_in_club(user)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.subscriptions.all().delete()
|
||||
super().delete()
|
||||
|
||||
def fetch_format(self):
|
||||
destination = "".join(s.fetch_format() for s in self.subscriptions.all())
|
||||
return f"{self.email}: {destination}"
|
||||
|
||||
|
||||
class MailingSubscription(models.Model):
|
||||
"""Link between user and mailing list."""
|
||||
|
||||
mailing = models.ForeignKey(
|
||||
Mailing,
|
||||
verbose_name=_("Mailing"),
|
||||
related_name="subscriptions",
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
verbose_name=_("User"),
|
||||
related_name="mailing_subscriptions",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
email = models.EmailField(_("Email address"), blank=False, null=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("user", "email", "mailing"),)
|
||||
|
||||
def __str__(self):
|
||||
return "(%s) - %s : %s" % (self.mailing, self.get_username, self.email)
|
||||
|
||||
def clean(self):
|
||||
if not self.user and not self.email:
|
||||
raise ValidationError(_("At least user or email is required"))
|
||||
try:
|
||||
if self.user and not self.email:
|
||||
self.email = self.user.email
|
||||
if MailingSubscription.objects.filter(
|
||||
mailing=self.mailing, email=self.email
|
||||
).exists():
|
||||
raise ValidationError(
|
||||
_("This email is already suscribed in this mailing")
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
super().clean()
|
||||
|
||||
def is_owned_by(self, user):
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return (
|
||||
self.mailing.club.has_rights_in_club(user)
|
||||
or user.is_root
|
||||
or self.user.is_com_admin
|
||||
)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
return self.user is not None and user.id == self.user.id
|
||||
|
||||
@cached_property
|
||||
def get_email(self):
|
||||
if self.user and not self.email:
|
||||
return self.user.email
|
||||
return self.email
|
||||
|
||||
@cached_property
|
||||
def get_username(self):
|
||||
if self.user:
|
||||
return str(self.user)
|
||||
return _("Unregistered user")
|
||||
|
||||
def fetch_format(self):
|
||||
return self.get_email + " "
|
||||
|
||||
|
||||
class LinkType(models.Model):
|
||||
"""A link type, in order to group links and give them icons.
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if user.is_authenticated and user.can_edit(club) %}
|
||||
<a
|
||||
href="{{ url("core:page_edit", page_name=club.page.get_full_name()) }}"
|
||||
class="btn btn-blue margin-bottom"
|
||||
>
|
||||
<i class="fa fa-pen-to-square"></i> {% trans %}Edit{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<h3>{{ club.name }}</h3>
|
||||
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
|
||||
<div id="club-attributes">
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
{% if club.has_rights_in_club(user) %}
|
||||
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}Create news{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for semester_code, semester_events in events %}
|
||||
<h3>{{ semester_code }}</h3>
|
||||
{% for event in semester_events %}
|
||||
<details class="accordion">
|
||||
<summary>{{ event.title }}</summary>
|
||||
<div class="accordion-content">
|
||||
|
||||
{# Events that occur weekly from the start to the end of the semester
|
||||
would take a lot of space if shown by default.
|
||||
Thus, if the event has more than one date, those are
|
||||
hidden by a details element.
|
||||
If there is only one date, it is directly displayed. #}
|
||||
{% set dates = event.dates.all()|list %}
|
||||
{% if dates|length == 1 %}
|
||||
<em>
|
||||
<time datetime="{{ dates[0].start_date.isoformat(timespec="seconds") }}">
|
||||
{{ dates[0].start_date|localtime|date(DATETIME_FORMAT) }},
|
||||
{{ dates[0].start_date|localtime|time(DATETIME_FORMAT) }}
|
||||
</time> -
|
||||
<time datetime="{{ dates[0].end_date.isoformat(timespec="seconds") }}">
|
||||
{{ dates[0].end_date|localtime|date(DATETIME_FORMAT) }},
|
||||
{{ dates[0].end_date|localtime|time(DATETIME_FORMAT) }}
|
||||
</time>
|
||||
</em>
|
||||
{% else %}
|
||||
<details>
|
||||
<summary>
|
||||
{% trans trimmed nb_occurrences=dates|length %}
|
||||
Dates ({{ nb_occurrences }} occurrences)
|
||||
{% endtrans %}
|
||||
</summary>
|
||||
<ul>
|
||||
{% for d in dates %}
|
||||
<li>
|
||||
<time datetime="{{ d.start_date.isoformat(timespec="seconds") }}">
|
||||
{{ d.start_date|localtime|date(DATETIME_FORMAT) }},
|
||||
{{ d.start_date|localtime|time(DATETIME_FORMAT) }}
|
||||
</time> -
|
||||
<time datetime="{{ d.end_date.isoformat(timespec="seconds") }}">
|
||||
{{ d.end_date|localtime|date(DATETIME_FORMAT) }},
|
||||
{{ d.end_date|localtime|time(DATETIME_FORMAT) }}
|
||||
</time>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<h4>{% trans %}Permalink{% endtrans %}</h4>
|
||||
<p>{{ request.build_absolute_uri(event.get_absolute_url())|urlize }}</p>
|
||||
|
||||
<h4>{% trans %}Summary{% endtrans %}</h4>
|
||||
{% if event.summary %}
|
||||
{{ event.summary|markdown }}
|
||||
{% else %}
|
||||
<p><em>{% trans %}No summary{% endtrans %}</em></p>
|
||||
{% endif %}
|
||||
|
||||
<h4>{% trans %}Description{% endtrans %}</h4>
|
||||
{% if event.content %}
|
||||
{{ event.content|markdown }}
|
||||
{% else %}
|
||||
<p><em>{% trans %}No detailed description{% endtrans %}</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
<br />
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -15,9 +15,6 @@
|
||||
{% trans %}Post in the Weekmail{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% if object.can_roles_be_edited_by(user) %}
|
||||
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
|
||||
{% endif %}
|
||||
{% if object.trombi %}
|
||||
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
||||
{% else %}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/macros.jinja' import select_all_checkbox %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Mailing lists{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<b>{% trans %}Remember : mailing lists need to be moderated, if your new created list is not shown wait until moderation takes action{% endtrans %}</b>
|
||||
|
||||
{% if mailings_not_moderated %}
|
||||
<p>{% trans %}Mailing lists waiting for moderation{% endtrans %}</p>
|
||||
<ul>
|
||||
{% for mailing in mailings_not_moderated %}
|
||||
<li>{{ mailing.email_full }}<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if mailings_moderated %}
|
||||
{% for mailing in mailings_moderated %}
|
||||
<h2>{% trans %}Mailing{% endtrans %} {{ mailing.email_full }}
|
||||
{%- if user.is_owner(mailing) -%}
|
||||
<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a>
|
||||
{%- endif -%}
|
||||
</h2>
|
||||
<form method="GET" action="{{ url('club:mailing_generate', mailing_id=mailing.id) }}" style="display:inline-block;">
|
||||
<input type="submit" name="generateMalingList" value="{% trans %}Generate mailing list{% endtrans %}">
|
||||
</form>
|
||||
{% set form_mailing_removal = form["removal_" + mailing.id|string] %}
|
||||
{% if form_mailing_removal.field.choices %}
|
||||
{% set ms = dict(mailing.subscriptions.all() | groupby('id')) %}
|
||||
<form action="{{ url('club:mailing', club_id=club.id) }}" id="{{ form_mailing_removal.auto_id }}" method="post" enctype="multipart/form-data">
|
||||
<p style="margin-bottom: 1em;">{{ select_all_checkbox(form_mailing_removal.auto_id) }}</p>
|
||||
{% csrf_token %}
|
||||
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.REMOVE_SUBSCRIPTION }}" />
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}User{% endtrans %}</td>
|
||||
<td>{% trans %}Email{% endtrans %}</td>
|
||||
<td>{% trans %}Delete{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for widget in form_mailing_removal.subwidgets %}
|
||||
{% set user = ms[widget.data.value.value][0] %}
|
||||
<tr>
|
||||
<td>{{ user.get_username }}</td>
|
||||
<td>{{ user.get_email }}</td>
|
||||
<td>{{ widget.tag() }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ form_mailing_removal.errors }}
|
||||
<p><input type="submit" value="{% trans %}Remove from mailing list{% endtrans %}" /></p>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
<p><b>{% trans %}There is no subscriber for this mailing list{% endtrans %}</b></p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<p>{% trans %}No mailing list existing for this club{% endtrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>{{ form.non_field_errors() }}</p>
|
||||
{% if mailings_moderated %}
|
||||
<h2>{% trans %}New member{% endtrans %}</h2>
|
||||
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{{ form.subscription_mailing.errors }}
|
||||
<label for="{{ form.subscription_mailing.id_for_label }}">{{ form.subscription_mailing.label }}</label>
|
||||
{{ form.subscription_mailing }}
|
||||
</p>
|
||||
<p>
|
||||
{{ form.subscription_users.errors }}
|
||||
<label for="{{ form.subscription_users.id_for_label }}">{{ form.subscription_users.label }}</label>
|
||||
{{ form.subscription_users }}
|
||||
<span class="helptext">{{ form.subscription_users.help_text }}</span>
|
||||
</p>
|
||||
<p>
|
||||
{{ form.subscription_email.errors }}
|
||||
<label for="{{ form.subscription_email.id_for_label }}">{{ form.subscription_email.label }}</label>
|
||||
{{ form.subscription_email }}
|
||||
</p>
|
||||
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_SUBSCRIPTION }}" />
|
||||
<p><input type="submit" value="{% trans %}Add to mailing list{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h2>{% trans %}New mailing{% endtrans %}</h2>
|
||||
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{{ form.mailing_email.errors }}
|
||||
<label for="{{ form.mailing_email.id_for_label }}">{{ form.mailing_email.label }}</label>
|
||||
{{ form.mailing_email }}
|
||||
</p>
|
||||
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_MALING }}" />
|
||||
<p><input type="submit" value="{% trans %}Create mailing list{% endtrans %}" /></p>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Club
|
||||
from club.views import ClubNewsView
|
||||
from com.models import News, NewsDate
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
|
||||
|
||||
class TestClubNews(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = baker.make(Club)
|
||||
cls.news = baker.make(
|
||||
News, club=cls.club, is_published=True, author=baker.make(User), _quantity=5
|
||||
)
|
||||
d = datetime(year=2025, month=10, day=1, tzinfo=get_current_timezone())
|
||||
dates = [
|
||||
baker.prepare(NewsDate, news=cls.news[0], start_date=d),
|
||||
baker.prepare(NewsDate, news=cls.news[0], start_date=d.replace(day=8)),
|
||||
baker.prepare(NewsDate, news=cls.news[0], start_date=d.replace(day=15)),
|
||||
baker.prepare(NewsDate, news=cls.news[1], start_date=d.replace(month=5)),
|
||||
baker.prepare(NewsDate, news=cls.news[2], start_date=d.replace(month=4)),
|
||||
baker.prepare(NewsDate, news=cls.news[3], start_date=d.replace(year=2024)),
|
||||
baker.prepare(NewsDate, news=cls.news[4], start_date=d.replace(year=2020)),
|
||||
]
|
||||
NewsDate.objects.bulk_create(dates)
|
||||
cls.url = reverse("club:news_list", kwargs={"club_id": cls.club.id})
|
||||
|
||||
def test_load_page(self):
|
||||
"""Just test that the page loads."""
|
||||
self.client.force_login(subscriber_user.make())
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_page_data(self):
|
||||
"""Test that we display the good events."""
|
||||
# For this test, the object we want to test
|
||||
# is built with `itertools.groupby`.
|
||||
# However, it is consumed when rendering the template,
|
||||
# making it unusable for later testing purposes,
|
||||
# so we must use lower level django APIs in this test.
|
||||
request = RequestFactory().get(self.url)
|
||||
request.user = subscriber_user.make()
|
||||
response = ClubNewsView.as_view()(request, club_id=self.club.id)
|
||||
events = [
|
||||
(semester_code, list(dates))
|
||||
for semester_code, dates in response.context_data["events"]
|
||||
]
|
||||
assert events == [
|
||||
("A25", [self.news[0]]),
|
||||
("P25", [self.news[1], self.news[2]]),
|
||||
("A24", [self.news[3]]),
|
||||
("A20", [self.news[4]]),
|
||||
]
|
||||
@@ -1,328 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
|
||||
from club.forms import MailingForm
|
||||
from club.models import Club, ClubRole, Mailing, Membership
|
||||
from core.models import User
|
||||
|
||||
|
||||
class TestMailingForm(TestCase):
|
||||
"""Perform validation tests for MailingForm."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.rbatsbak = User.objects.get(username="rbatsbak")
|
||||
cls.krophil = User.objects.get(username="krophil")
|
||||
cls.comunity = User.objects.get(username="comunity")
|
||||
cls.root = User.objects.get(username="root")
|
||||
cls.club = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
|
||||
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.club.id})
|
||||
Membership(
|
||||
user=cls.rbatsbak,
|
||||
club=cls.club,
|
||||
start_date=timezone.now(),
|
||||
role=baker.make(ClubRole, club=cls.club, is_board=True),
|
||||
).save()
|
||||
|
||||
def test_mailing_list_add_no_moderation(self):
|
||||
# Test with Communication admin
|
||||
self.client.force_login(self.comunity)
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
|
||||
)
|
||||
self.assertRedirects(response, self.mail_url)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "Liste de diffusion foyer@utbm.fr" in response.text
|
||||
|
||||
# Test with Root
|
||||
self.client.force_login(self.root)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "Liste de diffusion mde@utbm.fr" in response.text
|
||||
|
||||
def test_mailing_list_add_moderation(self):
|
||||
self.client.force_login(self.rbatsbak)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "Liste de diffusion mde@utbm.fr" not in content
|
||||
assert "<p>Listes de diffusions en attente de modération</p>" in content
|
||||
assert "<li>mde@utbm.fr" in content
|
||||
|
||||
def test_mailing_list_forbidden(self):
|
||||
# With anonymous user
|
||||
response = self.client.get(self.mail_url)
|
||||
self.assertContains(response, "", status_code=403)
|
||||
|
||||
# With user not in club
|
||||
self.client.force_login(self.krophil)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_add_new_subscription_fail_not_moderated(self):
|
||||
self.client.force_login(self.rbatsbak)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.skia.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "skia@git.an" not in response.text
|
||||
|
||||
def test_add_new_subscription_success(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
# Add single user
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.skia.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
assert "skia@git.an" in response.text
|
||||
|
||||
# Add multiple users
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": (self.comunity.id, self.rbatsbak.id),
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
|
||||
# Add arbitrary email
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_email": "arbitrary@git.an",
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
assert "arbitrary@git.an" in content
|
||||
|
||||
# Add user and arbitrary email
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_email": "more.arbitrary@git.an",
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "richard@git.an" in content
|
||||
assert "comunity@git.an" in content
|
||||
assert "skia@git.an" in content
|
||||
assert "arbitrary@git.an" in content
|
||||
assert "more.arbitrary@git.an" in content
|
||||
assert "krophil@git.an" in content
|
||||
|
||||
def test_add_new_subscription_fail_form_errors(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
|
||||
# Neither email or email is specified
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code
|
||||
self.assertInHTML(
|
||||
_("You must specify at least an user or an email address"),
|
||||
response.text,
|
||||
)
|
||||
|
||||
# No mailing specified
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert _("This field is required") in response.text
|
||||
|
||||
# One of the selected users doesn't exist
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": [789],
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("You must specify at least an user or an email address"),
|
||||
response.text,
|
||||
)
|
||||
|
||||
# An user has no email address
|
||||
self.krophil.email = ""
|
||||
self.krophil.save()
|
||||
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("One of the selected users doesn't have an email address"),
|
||||
response.text,
|
||||
)
|
||||
|
||||
self.krophil.email = "krophil@git.an"
|
||||
self.krophil.save()
|
||||
|
||||
# An user is added twice
|
||||
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": self.krophil.id,
|
||||
"subscription_mailing": Mailing.objects.get(email="mde").id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
_("This email is already suscribed in this mailing"),
|
||||
response.text,
|
||||
)
|
||||
|
||||
def test_remove_subscription_success(self):
|
||||
# Prepare mailing list
|
||||
self.client.force_login(self.comunity)
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
|
||||
)
|
||||
mde = Mailing.objects.get(email="mde")
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
|
||||
"subscription_users": (
|
||||
self.comunity.id,
|
||||
self.rbatsbak.id,
|
||||
self.krophil.id,
|
||||
),
|
||||
"subscription_mailing": mde.id,
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
|
||||
assert "comunity@git.an" in content
|
||||
assert "richard@git.an" in content
|
||||
assert "krophil@git.an" in content
|
||||
|
||||
# Delete one user
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
|
||||
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
|
||||
assert "comunity@git.an" in content
|
||||
assert "richard@git.an" in content
|
||||
assert "krophil@git.an" not in content
|
||||
|
||||
# Delete multiple users
|
||||
self.client.post(
|
||||
self.mail_url,
|
||||
{
|
||||
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
|
||||
"removal_%d" % mde.id: [
|
||||
user.id
|
||||
for user in mde.subscriptions.filter(
|
||||
user__in=[self.rbatsbak, self.comunity]
|
||||
).all()
|
||||
],
|
||||
},
|
||||
)
|
||||
response = self.client.get(self.mail_url)
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
|
||||
assert "comunity@git.an" not in content
|
||||
assert "richard@git.an" not in content
|
||||
assert "krophil@git.an" not in content
|
||||
+57
-9
@@ -1,8 +1,9 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
import pytest
|
||||
from aemark import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||
@@ -16,13 +17,20 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_display_on_club_main_page(client: Client):
|
||||
@pytest.mark.parametrize("user_factory", [lambda: None, subscriber_user.make])
|
||||
def test_page_display_on_club_main_page(
|
||||
client: Client, user_factory: Callable[[], User | None]
|
||||
):
|
||||
"""Test the club Page is properly displayed on the club main view"""
|
||||
club = baker.make(Club)
|
||||
# create another previous revision, to test that we display the most recent one
|
||||
baker.make(PageRev, page=club.page, revision=1)
|
||||
content = "# foo\nLorem ipsum dolor sit amet"
|
||||
baker.make(PageRev, page=club.page, revision=1, content=content)
|
||||
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||
baker.make(PageRev, page=club.page, revision=2, content=content)
|
||||
|
||||
if user := user_factory():
|
||||
client.force_login(user)
|
||||
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||
assert res.status_code == 200
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
detail_html = soup.find(id="club-page").find(class_="markdown")
|
||||
@@ -30,20 +38,60 @@ def test_page_display_on_club_main_page(client: Client):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_club_main_page_without_content(client: Client):
|
||||
"""Test the club view works, even if the club page is empty"""
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "can_view_tabs", "can_view_edit_button"),
|
||||
[
|
||||
(lambda _: None, False, False),
|
||||
(lambda _: baker.make(User), False, False),
|
||||
(lambda _: subscriber_user.make(), True, False),
|
||||
(
|
||||
# admin user
|
||||
lambda _: subscriber_user.make(
|
||||
user_permissions=[Permission.objects.get(codename="change_club")]
|
||||
),
|
||||
True,
|
||||
True,
|
||||
),
|
||||
(
|
||||
# club board member
|
||||
lambda club: (
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
role=baker.make(ClubRole, is_board=True),
|
||||
user=subscriber_user.make(),
|
||||
).user
|
||||
),
|
||||
True,
|
||||
True,
|
||||
),
|
||||
],
|
||||
ids=["anonymous", "public", "subscriber", "admin", "club board"],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_club_main_page_displayed_elements(
|
||||
client: Client, user_factory, can_view_tabs, can_view_edit_button
|
||||
):
|
||||
"""Test that the appropriate elements are displayed depending on user status."""
|
||||
club = baker.make(Club)
|
||||
club.page.revisions.all().delete()
|
||||
if user := user_factory(club):
|
||||
client.force_login(user)
|
||||
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||
|
||||
assert res.status_code == 200
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
detail_html = soup.find(id="club-page")
|
||||
assert detail_html.find_all("markdown") == []
|
||||
tabs = soup.find(class_="tool_bar")
|
||||
assert tabs is not None if can_view_tabs else tabs is None
|
||||
edit_btn = soup.find(
|
||||
"a",
|
||||
href=reverse("core:page_edit", kwargs={"page_name": club.page.get_full_name()}),
|
||||
)
|
||||
assert edit_btn is not None if can_view_edit_button else edit_btn is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_revision(client: Client):
|
||||
"""Test that accessing a previous revision of the club page works."""
|
||||
club = baker.make(Club)
|
||||
revisions = baker.make(
|
||||
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
|
||||
|
||||
+2
-20
@@ -29,8 +29,8 @@ from club.views import (
|
||||
ClubCreateView,
|
||||
ClubEditView,
|
||||
ClubListView,
|
||||
ClubMailingView,
|
||||
ClubMembersView,
|
||||
ClubNewsView,
|
||||
ClubOldMembersView,
|
||||
ClubPageEditView,
|
||||
ClubPageHistView,
|
||||
@@ -43,9 +43,6 @@ from club.views import (
|
||||
ClubSellingView,
|
||||
ClubToolsView,
|
||||
ClubView,
|
||||
MailingAutoGenerationView,
|
||||
MailingDeleteView,
|
||||
MailingSubscriptionDeleteView,
|
||||
MembershipDeleteView,
|
||||
MembershipSetOldView,
|
||||
PosterCreateView,
|
||||
@@ -96,22 +93,6 @@ urlpatterns = [
|
||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||
),
|
||||
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"),
|
||||
path("<int:club_id>/mailing/", ClubMailingView.as_view(), name="mailing"),
|
||||
path(
|
||||
"<int:mailing_id>/mailing/generate/",
|
||||
MailingAutoGenerationView.as_view(),
|
||||
name="mailing_generate",
|
||||
),
|
||||
path(
|
||||
"<int:mailing_id>/mailing/delete/",
|
||||
MailingDeleteView.as_view(),
|
||||
name="mailing_delete",
|
||||
),
|
||||
path(
|
||||
"<int:mailing_subscription_id>/mailing/delete/subscription/",
|
||||
MailingSubscriptionDeleteView.as_view(),
|
||||
name="mailing_subscription_delete",
|
||||
),
|
||||
path(
|
||||
"membership/<int:membership_id>/set_old/",
|
||||
MembershipSetOldView.as_view(),
|
||||
@@ -136,4 +117,5 @@ urlpatterns = [
|
||||
PosterDeleteView.as_view(),
|
||||
name="poster_delete",
|
||||
),
|
||||
path("<int:club_id>/news/", ClubNewsView.as_view(), name="news_list"),
|
||||
]
|
||||
+58
-217
@@ -34,9 +34,9 @@ from django.contrib.auth.mixins import (
|
||||
UserPassesTestMixin,
|
||||
)
|
||||
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, Prefetch, Q, Sum
|
||||
from django.db.models.expressions import OuterRef, Subquery
|
||||
from django.db.models.functions import Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
@@ -59,7 +59,6 @@ from club.forms import (
|
||||
ClubRoleFormSet,
|
||||
ClubSearchForm,
|
||||
JoinClubForm,
|
||||
MailingForm,
|
||||
SellingsForm,
|
||||
)
|
||||
from club.models import (
|
||||
@@ -67,11 +66,9 @@ from club.models import (
|
||||
ClubLink,
|
||||
ClubRole,
|
||||
LinkType,
|
||||
Mailing,
|
||||
MailingSubscription,
|
||||
Membership,
|
||||
)
|
||||
from com.models import Poster
|
||||
from com.models import NewsDate, Poster
|
||||
from com.views import (
|
||||
PosterCreateBaseView,
|
||||
PosterDeleteBaseView,
|
||||
@@ -80,6 +77,7 @@ from com.views import (
|
||||
)
|
||||
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
|
||||
from core.models import Page, PageRev
|
||||
from core.utils import get_semester_code
|
||||
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
|
||||
from core.views.mixins import (
|
||||
AllowFragment,
|
||||
@@ -106,42 +104,40 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
return self.object.get_display_name()
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
if not self.request.user.has_perm("club.view_club"):
|
||||
# The user will still be able to see the club main page,
|
||||
# but nothing else.
|
||||
# It's useless to display a tab with only one item, so return nothing.
|
||||
return []
|
||||
tab_list = [
|
||||
{
|
||||
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
|
||||
"slug": "infos",
|
||||
"name": _("Infos"),
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": reverse("club:club_members", kwargs={"club_id": self.object.id}),
|
||||
"slug": "members",
|
||||
"name": _("Members"),
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"club:club_old_members", kwargs={"club_id": self.object.id}
|
||||
),
|
||||
"slug": "elderlies",
|
||||
"name": _("Old members"),
|
||||
},
|
||||
{
|
||||
"url": reverse("club:club_hist", kwargs={"club_id": self.object.id}),
|
||||
"slug": "history",
|
||||
"name": _("History"),
|
||||
},
|
||||
{
|
||||
"url": reverse("club:news_list", kwargs={"club_id": self.object.id}),
|
||||
"slug": "events",
|
||||
"name": _("Events"),
|
||||
},
|
||||
]
|
||||
if self.request.user.has_perm("club.view_club"):
|
||||
tab_list.extend(
|
||||
[
|
||||
{
|
||||
"url": reverse(
|
||||
"club:club_members", kwargs={"club_id": self.object.id}
|
||||
),
|
||||
"slug": "members",
|
||||
"name": _("Members"),
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"club:club_old_members", kwargs={"club_id": self.object.id}
|
||||
),
|
||||
"slug": "elderlies",
|
||||
"name": _("Old members"),
|
||||
},
|
||||
]
|
||||
)
|
||||
if self.object.page:
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"club:club_hist", kwargs={"club_id": self.object.id}
|
||||
),
|
||||
"slug": "history",
|
||||
"name": _("History"),
|
||||
}
|
||||
)
|
||||
if self.request.user.can_edit(self.object):
|
||||
tab_list.extend(
|
||||
[
|
||||
@@ -159,21 +155,6 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
"slug": "edit",
|
||||
"name": _("Edit"),
|
||||
},
|
||||
]
|
||||
)
|
||||
if self.object.page and self.request.user.can_edit(self.object.page):
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"core:page_edit",
|
||||
kwargs={"page_name": self.object.page.get_full_name()},
|
||||
),
|
||||
"slug": "page_edit",
|
||||
"name": _("Edit club page"),
|
||||
}
|
||||
)
|
||||
tab_list.extend(
|
||||
[
|
||||
{
|
||||
"url": reverse(
|
||||
"club:club_sellings", kwargs={"club_id": self.object.id}
|
||||
@@ -181,13 +162,6 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
"slug": "sellings",
|
||||
"name": _("Sellings"),
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"club:mailing", kwargs={"club_id": self.object.id}
|
||||
),
|
||||
"slug": "mailing",
|
||||
"name": _("Mailing list"),
|
||||
},
|
||||
{
|
||||
"url": reverse(
|
||||
"club:poster_list", kwargs={"club_id": self.object.id}
|
||||
@@ -742,166 +716,6 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
|
||||
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user_id})
|
||||
|
||||
|
||||
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||
"""A list of mailing for a given club."""
|
||||
|
||||
model = Club
|
||||
form_class = MailingForm
|
||||
pk_url_kwarg = "club_id"
|
||||
template_name = "club/mailing.jinja"
|
||||
current_tab = "mailing"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["club_id"] = self.object.id
|
||||
kwargs["user_id"] = self.request.user.id
|
||||
kwargs["mailings"] = self.object.mailings.all()
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
mailings = list(self.object.mailings.all())
|
||||
kwargs["club"] = self.object
|
||||
kwargs["user"] = self.request.user
|
||||
kwargs["mailings"] = mailings
|
||||
kwargs["mailings_moderated"] = [m for m in mailings if m.is_moderated]
|
||||
kwargs["mailings_not_moderated"] = [m for m in mailings if not m.is_moderated]
|
||||
kwargs["form_actions"] = {
|
||||
"NEW_MALING": self.form_class.ACTION_NEW_MAILING,
|
||||
"NEW_SUBSCRIPTION": self.form_class.ACTION_NEW_SUBSCRIPTION,
|
||||
"REMOVE_SUBSCRIPTION": self.form_class.ACTION_REMOVE_SUBSCRIPTION,
|
||||
}
|
||||
return kwargs
|
||||
|
||||
def add_new_mailing(self, cleaned_data) -> ValidationError | None:
|
||||
"""Create a new mailing list from the form."""
|
||||
mailing = Mailing(
|
||||
club=self.object,
|
||||
email=cleaned_data["mailing_email"],
|
||||
moderator=self.request.user,
|
||||
is_moderated=False,
|
||||
)
|
||||
try:
|
||||
mailing.clean()
|
||||
except ValidationError as validation_error:
|
||||
return validation_error
|
||||
mailing.save()
|
||||
return None
|
||||
|
||||
def add_new_subscription(self, cleaned_data) -> ValidationError | None:
|
||||
"""Add mailing subscriptions for each user given and/or for the specified email in form."""
|
||||
users_to_save = []
|
||||
|
||||
for user in cleaned_data["subscription_users"]:
|
||||
sub = MailingSubscription(
|
||||
mailing=cleaned_data["subscription_mailing"], user=user
|
||||
)
|
||||
try:
|
||||
sub.clean()
|
||||
except ValidationError as validation_error:
|
||||
return validation_error
|
||||
|
||||
sub.save()
|
||||
users_to_save.append(sub)
|
||||
|
||||
if cleaned_data["subscription_email"]:
|
||||
sub = MailingSubscription(
|
||||
mailing=cleaned_data["subscription_mailing"],
|
||||
email=cleaned_data["subscription_email"],
|
||||
)
|
||||
|
||||
try:
|
||||
sub.clean()
|
||||
except ValidationError as validation_error:
|
||||
return validation_error
|
||||
sub.save()
|
||||
|
||||
# Save users after we are sure there is no error
|
||||
for user in users_to_save:
|
||||
user.save()
|
||||
|
||||
return None
|
||||
|
||||
def remove_subscription(self, cleaned_data):
|
||||
"""Remove specified users from a mailing list."""
|
||||
fields = [
|
||||
val for key, val in cleaned_data.items() if key.startswith("removal_")
|
||||
]
|
||||
for field in fields:
|
||||
for sub in field:
|
||||
sub.delete()
|
||||
|
||||
def form_valid(self, form):
|
||||
resp = super().form_valid(form)
|
||||
|
||||
cleaned_data = form.clean()
|
||||
error = None
|
||||
|
||||
if cleaned_data["action"] == self.form_class.ACTION_NEW_MAILING:
|
||||
error = self.add_new_mailing(cleaned_data)
|
||||
|
||||
if cleaned_data["action"] == self.form_class.ACTION_NEW_SUBSCRIPTION:
|
||||
error = self.add_new_subscription(cleaned_data)
|
||||
|
||||
if cleaned_data["action"] == self.form_class.ACTION_REMOVE_SUBSCRIPTION:
|
||||
self.remove_subscription(cleaned_data)
|
||||
|
||||
if error:
|
||||
form.add_error(NON_FIELD_ERRORS, error)
|
||||
return self.form_invalid(form)
|
||||
|
||||
return resp
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse("club:mailing", kwargs={"club_id": self.object.id})
|
||||
|
||||
|
||||
class MailingDeleteView(CanEditMixin, DeleteView):
|
||||
model = Mailing
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
pk_url_kwarg = "mailing_id"
|
||||
redirect_page = None
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.club_id = self.get_object().club.id
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
if self.redirect_page:
|
||||
return reverse_lazy(self.redirect_page)
|
||||
else:
|
||||
return reverse_lazy("club:mailing", kwargs={"club_id": self.club_id})
|
||||
|
||||
|
||||
class MailingSubscriptionDeleteView(CanEditMixin, DeleteView):
|
||||
model = MailingSubscription
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
pk_url_kwarg = "mailing_subscription_id"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.club_id = self.get_object().mailing.club.id
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy("club:mailing", kwargs={"club_id": self.club_id})
|
||||
|
||||
|
||||
class MailingAutoGenerationView(View):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.mailing = get_object_or_404(Mailing, pk=kwargs["mailing_id"])
|
||||
if not request.user.can_edit(self.mailing):
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
club = self.mailing.club
|
||||
self.mailing.subscriptions.all().delete()
|
||||
members = club.members.ongoing().filter(role__is_board=True)
|
||||
for member in members.all():
|
||||
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
||||
return redirect("club:mailing", club_id=club.id)
|
||||
|
||||
|
||||
class PosterListView(
|
||||
PermissionOrClubBoardRequiredMixin, ClubTabsMixin, PosterListBaseView
|
||||
):
|
||||
@@ -957,3 +771,30 @@ class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView):
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
|
||||
|
||||
|
||||
class ClubNewsView(PermissionRequiredMixin, ClubTabsMixin, DetailView):
|
||||
pk_url_kwarg = "club_id"
|
||||
model = Club
|
||||
permission_required = "club.view_club"
|
||||
template_name = "club/club_news_list.jinja"
|
||||
current_tab = "events"
|
||||
|
||||
def get_context_data(self, **kwargs: Any):
|
||||
events = list(
|
||||
self.object.news.viewable_by(self.request.user)
|
||||
.annotate(
|
||||
first_date=Subquery(
|
||||
NewsDate.objects.filter(news_id=OuterRef("pk"))
|
||||
.order_by("start_date")
|
||||
.values("start_date")[:1]
|
||||
),
|
||||
)
|
||||
.prefetch_related("dates")
|
||||
.order_by("-first_date")
|
||||
)
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"events": itertools.groupby(
|
||||
events, key=lambda event: get_semester_code(event.first_date.date())
|
||||
),
|
||||
}
|
||||
+1
-4
@@ -259,10 +259,7 @@ class Weekmail(models.Model):
|
||||
return f"Weekmail {self.id} (sent: {self.sent}) - {self.title}"
|
||||
|
||||
def send(self):
|
||||
"""Send the weekmail to all users with the receive weekmail option opt-in.
|
||||
|
||||
Also send the weekmail to the mailing list in settings.SITH_COM_EMAIL.
|
||||
"""
|
||||
"""Send the weekmail to all users with the receive weekmail option opt-in."""
|
||||
dest = [
|
||||
i[0]
|
||||
for i in Preferences.objects.filter(receive_weekmail=True).values_list(
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
margin-top: 20px;
|
||||
padding: 0.4em;
|
||||
width: 80%;
|
||||
background: $white-color;
|
||||
|
||||
h4 {
|
||||
margin-top: 1em;
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
h3 {
|
||||
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
|
||||
background: lighten($second-color, 5%);
|
||||
background: var(--bg-yellow);
|
||||
box-shadow: var(--box-shadow);
|
||||
padding: .75rem;
|
||||
margin: 0 0 0.5em 0;
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
#links_content {
|
||||
overflow: auto;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
min-height: 20em;
|
||||
padding: 1em;
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
}
|
||||
|
||||
#birthdays_content {
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
padding: 1em;
|
||||
|
||||
ul.birthdays_year {
|
||||
@@ -123,7 +123,7 @@
|
||||
padding: 0.5em;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: $secondary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
/* EVENTS TODAY AND NEXT FEW DAYS */
|
||||
.news_events_group {
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@@ -153,8 +153,8 @@
|
||||
display: table-cell;
|
||||
padding: 0.6em;
|
||||
vertical-align: middle;
|
||||
background: $primary-neutral-dark-color;
|
||||
color: $white-color;
|
||||
background: var(--deepblue);
|
||||
color: var(--text-white);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
@@ -180,7 +180,7 @@
|
||||
}
|
||||
|
||||
.news_event:nth-of-type(even) {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-white);
|
||||
}
|
||||
|
||||
.news_event {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Mailing lists administration{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_mailings(list) %}
|
||||
<table>
|
||||
<tr>
|
||||
<th>{% trans %}Email{% endtrans %}</th>
|
||||
<th>{% trans %}Club{%endtrans%}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
</tr>
|
||||
{% for mailing in list %}
|
||||
<tr>
|
||||
<td>{{ mailing.email_full }}</td>
|
||||
<td><a href="{{ url('club:mailing', club_id=mailing.club.id) }}">{{ mailing.club }}</a></td>
|
||||
<td>
|
||||
<a href="{{ url('com:mailing_delete', mailing_id=mailing.id) }}">{% trans %}Delete{% endtrans %}</a> - {% if not mailing.is_moderated %}<a href="{{ url('com:mailing_moderate', mailing_id=mailing.id) }}">{% trans %}Moderate{% endtrans %}</a>{% else %}{% trans user=mailing.moderator %}Moderated by {{ user }}{% endtrans %}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans %}This page lists all mailing lists{% endtrans %}</h1>
|
||||
|
||||
{% if has_unmoderated %}
|
||||
<h2>{% trans %}Not moderated mailing lists{% endtrans %}</h2>
|
||||
{{ display_mailings(unmoderated) }}
|
||||
{% endif %}
|
||||
|
||||
<h2>{% trans %}Moderated mailing lists{% endtrans %}</h2>
|
||||
{% if has_moderated %}
|
||||
{{ display_mailings(moderated) }}
|
||||
{% else %}
|
||||
<p>{% trans %}No mailing list existing{% endtrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -39,12 +39,18 @@
|
||||
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
|
||||
</div>
|
||||
<h4>{{ news.title }}</h4>
|
||||
<p class="date">
|
||||
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
|
||||
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
|
||||
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
|
||||
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
|
||||
</p>
|
||||
{% if date %}
|
||||
<p class="date">
|
||||
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
|
||||
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
|
||||
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
|
||||
</time> -
|
||||
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
|
||||
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
|
||||
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
|
||||
</time>
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="news_content">
|
||||
<div><em>{{ news.summary|markdown }}</em></div>
|
||||
<br/>
|
||||
|
||||
@@ -26,6 +26,7 @@ from django.utils import html
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
@@ -362,3 +363,37 @@ def test_moderate_poster(client: Client, referer: str | None):
|
||||
poster.refresh_from_db()
|
||||
assert poster.is_moderated
|
||||
assert poster.moderator == user
|
||||
|
||||
|
||||
class TestNewsDetail(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls) -> None:
|
||||
cls.news = baker.make(News, is_published=True)
|
||||
cls.user = subscriber_user.make()
|
||||
cls.url = reverse("com:news_detail", kwargs={"news_id": cls.news.id})
|
||||
|
||||
def test_page_ok(self):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_displayed_date(self):
|
||||
self.client.force_login(self.user)
|
||||
n = now()
|
||||
date_recipe = Recipe(NewsDate, news=self.news)
|
||||
date_recipe.make(
|
||||
start_date=n - timedelta(days=1, hours=6), end_date=n - timedelta(days=1)
|
||||
)
|
||||
date_recipe.make(
|
||||
start_date=n - timedelta(hours=6), end_date=n + timedelta(hours=1)
|
||||
)
|
||||
expected = date_recipe.make( # we expect the next happening date.
|
||||
start_date=n + timedelta(days=1), end_date=n + timedelta(days=1, hours=1)
|
||||
)
|
||||
date_recipe.make(
|
||||
start_date=n + timedelta(days=2), end_date=n + timedelta(days=2, hours=1)
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
assert res.context_data["date"] == expected
|
||||
-14
@@ -15,12 +15,9 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from club.views import MailingDeleteView
|
||||
from com.views import (
|
||||
AlertMsgEditView,
|
||||
InfoMsgEditView,
|
||||
MailingListAdminView,
|
||||
MailingModerateView,
|
||||
NewsAdminListView,
|
||||
NewsCreateView,
|
||||
NewsDeleteView,
|
||||
@@ -82,17 +79,6 @@ urlpatterns = [
|
||||
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
|
||||
),
|
||||
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
|
||||
path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
|
||||
path(
|
||||
"mailings/<int:mailing_id>/moderate/",
|
||||
MailingModerateView.as_view(),
|
||||
name="mailing_moderate",
|
||||
),
|
||||
path(
|
||||
"mailings/<int:mailing_id>/delete/",
|
||||
MailingDeleteView.as_view(redirect_page="com:mailing_admin"),
|
||||
name="mailing_delete",
|
||||
),
|
||||
path("poster/", PosterListView.as_view(), name="poster_list"),
|
||||
path("poster/create/", PosterCreateView.as_view(), name="poster_create"),
|
||||
path("poster/<int:poster_id>/edit/", PosterEditView.as_view(), name="poster_edit"),
|
||||
|
||||
+6
-42
@@ -30,11 +30,9 @@ 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 PermissionRequiredMixin
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Exists, Max, OuterRef, Value
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -46,7 +44,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, Membership
|
||||
from com.forms import NewsDateForm, NewsForm, PosterForm
|
||||
from com.ics_calendar import IcsCalendar
|
||||
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
|
||||
@@ -86,11 +84,6 @@ class ComTabsMixin(TabedViewMixin):
|
||||
"slug": "alert",
|
||||
"name": _("Alert message"),
|
||||
},
|
||||
{
|
||||
"url": reverse("com:mailing_admin"),
|
||||
"slug": "mailings",
|
||||
"name": _("Mailing lists administration"),
|
||||
},
|
||||
{
|
||||
"url": reverse("com:poster_list"),
|
||||
"slug": "posters",
|
||||
@@ -301,7 +294,9 @@ class NewsDetailView(CanViewMixin, DetailView):
|
||||
queryset = News.objects.select_related("club", "author", "moderator")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"date": self.object.dates.filter(start_date__gt=now()).first()
|
||||
}
|
||||
|
||||
|
||||
class NewsFeed(Feed):
|
||||
@@ -531,37 +526,6 @@ class WeekmailArticleDeleteView(CanEditPropMixin, DeleteView):
|
||||
pk_url_kwarg = "article_id"
|
||||
|
||||
|
||||
class MailingListAdminView(ComTabsMixin, ListView):
|
||||
template_name = "com/mailing_admin.jinja"
|
||||
model = Mailing
|
||||
current_tab = "mailings"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not (request.user.is_com_admin or request.user.is_root):
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["moderated"] = self.get_queryset().filter(is_moderated=True).all()
|
||||
kwargs["unmoderated"] = self.get_queryset().filter(is_moderated=False).all()
|
||||
kwargs["has_moderated"] = len(kwargs["moderated"]) > 0
|
||||
kwargs["has_unmoderated"] = len(kwargs["unmoderated"]) > 0
|
||||
return kwargs
|
||||
|
||||
|
||||
class MailingModerateView(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
mailing = get_object_or_404(Mailing, pk=kwargs["mailing_id"])
|
||||
if mailing.can_moderate(request.user):
|
||||
mailing.is_moderated = True
|
||||
mailing.moderator = request.user
|
||||
mailing.save()
|
||||
return redirect("com:mailing_admin")
|
||||
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
class PosterListBaseView(ListView):
|
||||
"""List communication posters."""
|
||||
|
||||
|
||||
-16
@@ -1,18 +1,15 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from annotated_types import Ge, Le, MinLen
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from ninja import File, Query
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.exceptions import PermissionDenied
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.permissions import CanAccessLookup, CanView, HasPerm
|
||||
from club.models import Mailing
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.schemas import (
|
||||
FamilyGodfatherSchema,
|
||||
@@ -48,19 +45,6 @@ class UploadController(ControllerBase):
|
||||
return image
|
||||
|
||||
|
||||
@api_controller("/mailings")
|
||||
class MailingListController(ControllerBase):
|
||||
@route.get("", response=str)
|
||||
def fetch_mailing_lists(self, key: str):
|
||||
if key != settings.SITH_MAILING_FETCH_KEY:
|
||||
raise PermissionDenied
|
||||
mailings = Mailing.objects.filter(
|
||||
is_moderated=True, club__is_active=True
|
||||
).prefetch_related("subscriptions")
|
||||
data = "\n".join(m.fetch_format() for m in mailings)
|
||||
return data
|
||||
|
||||
|
||||
@api_controller("/user")
|
||||
class UserController(ControllerBase):
|
||||
@route.get("", response=list[UserProfileSchema])
|
||||
|
||||
+11
-8
@@ -1,19 +1,16 @@
|
||||
class FourDigitYearConverter:
|
||||
regex = "[0-9]{4}"
|
||||
from django.urls.converters import IntConverter, StringConverter
|
||||
|
||||
def to_python(self, value):
|
||||
return int(value)
|
||||
|
||||
class FourDigitYearConverter(IntConverter):
|
||||
regex = "[0-9]{4}"
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value).zfill(4)
|
||||
|
||||
|
||||
class TwoDigitMonthConverter:
|
||||
class TwoDigitMonthConverter(IntConverter):
|
||||
regex = "[0-9]{2}"
|
||||
|
||||
def to_python(self, value):
|
||||
return int(value)
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value).zfill(2)
|
||||
|
||||
@@ -28,3 +25,9 @@ class BooleanStringConverter:
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class ResultConverter(StringConverter):
|
||||
"""Converter whose regex match either "success" or "failure"."""
|
||||
|
||||
regex = "(success|failure)"
|
||||
@@ -20,6 +20,7 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
import itertools
|
||||
from datetime import date, datetime, timedelta
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
@@ -120,11 +121,6 @@ class Command(BaseCommand):
|
||||
)
|
||||
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
|
||||
home_root = SithFile.objects.create(name="users", owner=root)
|
||||
|
||||
# Page needed for club creation
|
||||
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
|
||||
p.save(force_lock=True)
|
||||
|
||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||
sas = SithFile.objects.create(
|
||||
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
|
||||
@@ -256,6 +252,7 @@ class Command(BaseCommand):
|
||||
date_of_birth="1942-06-12",
|
||||
password="plop",
|
||||
)
|
||||
User.objects.all().update(cgu_approved_at=now())
|
||||
User.groups.through.objects.bulk_create(
|
||||
[
|
||||
User.groups.through(group=groups.counter_admin, user=counter),
|
||||
@@ -279,34 +276,7 @@ class Command(BaseCommand):
|
||||
]
|
||||
)
|
||||
|
||||
# Adding syntax help page
|
||||
syntax_page = Page(name="Aide_sur_la_syntaxe")
|
||||
syntax_page.save(force_lock=True)
|
||||
PageRev.objects.create(
|
||||
page=syntax_page,
|
||||
title="Aide sur la syntaxe",
|
||||
author=skia,
|
||||
content=(self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md").read_text(),
|
||||
)
|
||||
services_page = Page(name="Services")
|
||||
services_page.save(force_lock=True)
|
||||
PageRev.objects.create(
|
||||
page=services_page,
|
||||
title="Services",
|
||||
author=skia,
|
||||
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
|
||||
)
|
||||
|
||||
index_page = Page(name="Index")
|
||||
index_page.save(force_lock=True)
|
||||
PageRev.objects.create(
|
||||
page=index_page,
|
||||
title="Wiki index",
|
||||
author=root,
|
||||
content="Welcome to the wiki page!",
|
||||
)
|
||||
|
||||
groups.public.viewable_page.set([syntax_page, services_page, index_page])
|
||||
self._create_pages(groups)
|
||||
|
||||
self._create_subscription(root)
|
||||
self._create_subscription(skia)
|
||||
@@ -577,6 +547,48 @@ class Command(BaseCommand):
|
||||
]
|
||||
)
|
||||
|
||||
def _create_pages(self, groups: PopulatedGroups):
|
||||
pages = Page.objects.bulk_create(
|
||||
[Page(name=s, _full_name=s) for s in settings.SITH_CGU_PAGE.split("/")]
|
||||
)
|
||||
for parent, son in itertools.pairwise(pages):
|
||||
son.parent = parent
|
||||
son.save(force_lock=True)
|
||||
cgu_page = pages[-1]
|
||||
|
||||
syntax_page = Page(name="Aide_sur_la_syntaxe")
|
||||
syntax_page.save(force_lock=True)
|
||||
services_page = Page(name="Services")
|
||||
services_page.save(force_lock=True)
|
||||
index_page = Page(name="Index")
|
||||
index_page.save(force_lock=True)
|
||||
|
||||
page_revs = [
|
||||
PageRev(page=cgu_page, title="Règlement informatique", content=""),
|
||||
PageRev(
|
||||
page=syntax_page,
|
||||
title="Aide sur la syntaxe",
|
||||
content=(
|
||||
self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md"
|
||||
).read_text(),
|
||||
),
|
||||
PageRev(
|
||||
page=services_page,
|
||||
title="Services",
|
||||
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
|
||||
),
|
||||
PageRev(
|
||||
page=index_page, title="Wiki index", content="Welcome to the wiki page!"
|
||||
),
|
||||
]
|
||||
for rev in page_revs:
|
||||
rev.author_id = settings.SITH_ROOT_USER_ID
|
||||
rev.revision = 1
|
||||
PageRev.objects.bulk_create(page_revs)
|
||||
groups.public.viewable_page.set(
|
||||
[syntax_page, services_page, index_page, cgu_page]
|
||||
)
|
||||
|
||||
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
|
||||
beers_type, cotis_type, refill_type, verre_type = (
|
||||
ProductType.objects.bulk_create(
|
||||
@@ -737,6 +749,10 @@ class Command(BaseCommand):
|
||||
s.save()
|
||||
|
||||
def _create_clubs(self) -> PopulatedClubs:
|
||||
# Page needed for club creation
|
||||
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
|
||||
p.save(force_lock=True)
|
||||
|
||||
ae = Club.objects.create(
|
||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||
)
|
||||
@@ -848,7 +864,11 @@ class Command(BaseCommand):
|
||||
|
||||
subscribers = Group.objects.create(name="Cotisants")
|
||||
subscribers.permissions.add(
|
||||
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
|
||||
*list(
|
||||
perms.filter(
|
||||
codename__in=["add_news", "add_uecomment", "add_self_timetable"]
|
||||
)
|
||||
)
|
||||
)
|
||||
old_subscribers = Group.objects.create(name="Anciens cotisants")
|
||||
old_subscribers.permissions.add(
|
||||
@@ -877,18 +897,16 @@ class Command(BaseCommand):
|
||||
accounting_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
Q(
|
||||
codename__in=[
|
||||
"view_customer",
|
||||
"view_product",
|
||||
"change_product",
|
||||
"add_product",
|
||||
"view_producttype",
|
||||
"change_producttype",
|
||||
"add_producttype",
|
||||
"delete_selling",
|
||||
]
|
||||
)
|
||||
codename__in=[
|
||||
"view_customer",
|
||||
"view_product",
|
||||
"change_product",
|
||||
"add_product",
|
||||
"view_producttype",
|
||||
"change_producttype",
|
||||
"add_producttype",
|
||||
"delete_selling",
|
||||
]
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from datetime import timezone as tz
|
||||
@@ -35,12 +36,17 @@ class Command(BaseCommand):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.faker = Faker("fr_FR")
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-n", "--nb-users", help="Number of users to create", type=int, default=600
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not settings.DEBUG:
|
||||
raise Exception("Never call this command in prod. Never.")
|
||||
|
||||
self.stdout.write("Creating users...")
|
||||
users = self.create_users()
|
||||
users = self.create_users(options["nb_users"])
|
||||
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
|
||||
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
||||
self.stdout.write("Creating subscriptions...")
|
||||
@@ -80,7 +86,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write("Creating products...")
|
||||
self.create_products()
|
||||
self.stdout.write("Creating sales and refills...")
|
||||
sellers = random.sample(list(User.objects.all()), 100)
|
||||
sellers = random.sample(users, len(users) // 10)
|
||||
self.create_sales(sellers)
|
||||
self.stdout.write("Creating permanences...")
|
||||
self.create_permanences(sellers)
|
||||
@@ -89,7 +95,7 @@ class Command(BaseCommand):
|
||||
|
||||
self.stdout.write("Done")
|
||||
|
||||
def create_users(self) -> list[User]:
|
||||
def create_users(self, nb_users: int = 600) -> list[User]:
|
||||
# Create a single password hash for all users to make it faster.
|
||||
# It's insecure as hell, but it's ok since it's only for dev purposes.
|
||||
password = make_password("plop")
|
||||
@@ -108,7 +114,7 @@ class Command(BaseCommand):
|
||||
address=self.faker.address(),
|
||||
password=password,
|
||||
)
|
||||
for _ in range(600)
|
||||
for _ in range(nb_users)
|
||||
]
|
||||
# there may a duplicate or two
|
||||
# Not a problem, we will just have 599 users instead of 600
|
||||
@@ -138,10 +144,22 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def create_subscriptions(self, users: list[User]):
|
||||
subscription_types = [
|
||||
"un-semestre",
|
||||
"deux-semestres",
|
||||
"cursus-tronc-commun",
|
||||
"cursus-branche",
|
||||
]
|
||||
|
||||
def prepare_subscription(_user: User, start_date: date) -> Subscription:
|
||||
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
|
||||
duration = random.randint(1, 4)
|
||||
s = Subscription(member=_user, payment_method=payment_method)
|
||||
subscription_type = random.choice(subscription_types)
|
||||
s = Subscription(
|
||||
member=_user,
|
||||
payment_method=payment_method,
|
||||
subscription_type=subscription_type,
|
||||
)
|
||||
duration = settings.SITH_SUBSCRIPTIONS[subscription_type]["duration"]
|
||||
s.subscription_start = s.compute_start(d=start_date, duration=duration)
|
||||
s.subscription_end = s.compute_end(duration)
|
||||
return s
|
||||
@@ -415,8 +433,9 @@ class Command(BaseCommand):
|
||||
Permanency.objects.bulk_create(perms)
|
||||
|
||||
def create_forums(self):
|
||||
forumers = random.sample(list(User.objects.all()), 100)
|
||||
most_actives = random.sample(forumers, 10)
|
||||
users = list(User.objects.all())
|
||||
forumers = random.sample(users, math.ceil(len(users) / 10))
|
||||
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
|
||||
categories = list(Forum.objects.filter(is_category=True))
|
||||
new_forums = [
|
||||
Forum(name=self.faker.text(20), parent=random.choice(categories))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-25 12:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0050_alter_sithfile_moderator")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="cgu_approved_at",
|
||||
field=models.DateTimeField(null=True, verbose_name="ToS approved at"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-24 14:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0051_user_cgu_approved_at")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="preferences",
|
||||
name="show_my_timetable",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text=(
|
||||
"Allow subscribers to see your timetable and "
|
||||
"to compare it to theirs (if you have filled it)"
|
||||
),
|
||||
verbose_name="show your timetable to others",
|
||||
),
|
||||
),
|
||||
]
|
||||
+20
-4
@@ -44,6 +44,7 @@ from django.core.files.base import ContentFile
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.db.models.aggregates import Max
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
@@ -291,6 +292,7 @@ class User(AbstractUser):
|
||||
),
|
||||
blank=True,
|
||||
)
|
||||
cgu_approved_at = models.DateTimeField(_("ToS approved at"), null=True, blank=False)
|
||||
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
|
||||
|
||||
objects = CustomUserManager()
|
||||
@@ -418,6 +420,14 @@ class User(AbstractUser):
|
||||
)
|
||||
return age
|
||||
|
||||
@cached_property
|
||||
def approved_current_cgu(self) -> bool:
|
||||
qs = PageRev.objects.filter(page___full_name=settings.SITH_CGU_PAGE)
|
||||
return (
|
||||
self.cgu_approved_at is not None
|
||||
and self.cgu_approved_at > qs.aggregate(date=Max("date"))["date"]
|
||||
)
|
||||
|
||||
def make_home(self):
|
||||
if self.home is None:
|
||||
home_root = SithFile.objects.filter(parent=None, name="users").first()
|
||||
@@ -789,6 +799,14 @@ class Preferences(models.Model):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
show_my_timetable = models.BooleanField(
|
||||
_("show your timetable to others"),
|
||||
help_text=_(
|
||||
"Allow subscribers to see your timetable and "
|
||||
"to compare it to theirs (if you have filled it)"
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
notify_on_click = models.BooleanField(
|
||||
_("get a notification for every click"), default=False
|
||||
)
|
||||
@@ -1233,9 +1251,8 @@ class Page(models.Model):
|
||||
raise NotLocked("The page is not locked and thus can not be saved")
|
||||
self.full_clean()
|
||||
if not self.id:
|
||||
super().save(
|
||||
*args, **kwargs
|
||||
) # Save a first time to correctly set _full_name
|
||||
# Save a first time to correctly set _full_name
|
||||
super().save(*args, **kwargs)
|
||||
# This reset the _full_name just before saving to maintain a coherent field quicker for queries than the
|
||||
# recursive method
|
||||
# It also update all the children to maintain correct names
|
||||
@@ -1254,7 +1271,6 @@ class Page(models.Model):
|
||||
return Page.objects.filter(_full_name=name).first()
|
||||
|
||||
def clean(self):
|
||||
"""Cleans up only the name for the moment, but this can be used to make any treatment before saving the object."""
|
||||
if "/" in self.name:
|
||||
self.name = self.name.split("/")[-1]
|
||||
if (
|
||||
|
||||
@@ -60,6 +60,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
});
|
||||
easymde.codemirror.replaceSelection("\n");
|
||||
},
|
||||
previewClass: ["editor-preview", "markdown"], // default class + our own markdown class
|
||||
previewRender: (plainText) => markdown(plainText),
|
||||
forceSync: true, // Avoid validation error on generic create view
|
||||
imageTexts: {
|
||||
|
||||
@@ -42,6 +42,7 @@ details.accordion>.accordion-content {
|
||||
background: #ffffff;
|
||||
color: #333333;
|
||||
padding: 1em 2.2em;
|
||||
margin-top: 0;
|
||||
border: 1px solid #dddddd;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/*--------------------------------RESET--------------------------------*/
|
||||
/*--------------------------------RESET--------------------------------*/
|
||||
html,
|
||||
body,
|
||||
div,
|
||||
span,
|
||||
applet,
|
||||
object,
|
||||
iframe,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
blockquote,
|
||||
pre,
|
||||
a,
|
||||
abbr,
|
||||
acronym,
|
||||
address,
|
||||
big,
|
||||
cite,
|
||||
code,
|
||||
del,
|
||||
dfn,
|
||||
em,
|
||||
img,
|
||||
ins,
|
||||
kbd,
|
||||
q,
|
||||
s,
|
||||
samp,
|
||||
small,
|
||||
strike,
|
||||
sub,
|
||||
sup,
|
||||
tt,
|
||||
var,
|
||||
b,
|
||||
u,
|
||||
i,
|
||||
center,
|
||||
dl,
|
||||
dt,
|
||||
dd,
|
||||
ol,
|
||||
ul,
|
||||
li,
|
||||
fieldset,
|
||||
form,
|
||||
label,
|
||||
legend,
|
||||
table,
|
||||
caption,
|
||||
tbody,
|
||||
tfoot,
|
||||
thead,
|
||||
tr,
|
||||
th,
|
||||
td,
|
||||
article,
|
||||
aside,
|
||||
canvas,
|
||||
details,
|
||||
embed,
|
||||
figure,
|
||||
figcaption,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
menu,
|
||||
nav,
|
||||
output,
|
||||
ruby,
|
||||
section,
|
||||
summary,
|
||||
time,
|
||||
mark,
|
||||
audio,
|
||||
video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
/* HTML5 display-role reset for older browsers */
|
||||
article,
|
||||
aside,
|
||||
details,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
hgroup,
|
||||
menu,
|
||||
nav,
|
||||
section {
|
||||
display: block;
|
||||
}
|
||||
body {
|
||||
line-height: 1;
|
||||
}
|
||||
ol,
|
||||
ul {
|
||||
/* list-style: none;*/
|
||||
}
|
||||
blockquote,
|
||||
q {
|
||||
quotes: none;
|
||||
}
|
||||
blockquote:before,
|
||||
blockquote:after,
|
||||
q:before,
|
||||
q:after {
|
||||
content: none;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
@@ -1,41 +1,37 @@
|
||||
$first-color: hsl(220, 100%, 50%);
|
||||
$second-color: hsl(48, 100%, 67%);
|
||||
$primary-color: hsl(219.9, 53.7%, 50%);
|
||||
$secondary-color: hsl(204, 64%, 44%);
|
||||
$primary-color-text: hsl(0, 0%, 100%);
|
||||
$secondary-color-text: hsla(0, 0%, 0%, 0.87);
|
||||
/**
|
||||
* Sith theme variables
|
||||
*/
|
||||
:root {
|
||||
--bg-yellow: #ffe270;
|
||||
--bg-white: #f9fafb;
|
||||
--bg-off-white: #f0f0f0;
|
||||
--bg-darkened-off-white: #e3e3e3;
|
||||
--bg-lightgrey: #d4d4d4;
|
||||
|
||||
$primary-light-color: hsl(219.8, 46.4%, 64.9%);
|
||||
$primary-dark-color: hsl(203, 75%, 40%);
|
||||
--text-white: #f9fafb;
|
||||
--text-black: #2b2b2b;
|
||||
--text-red: #eb2f06;
|
||||
|
||||
$secondary-light-color: hsl(40, 68%, 65%);
|
||||
$secondary-dark-color: hsl(40, 68%, 35%);
|
||||
|
||||
$primary-neutral-color: hsl(219.6, 20.8%, 50%);
|
||||
$primary-neutral-light-color: hsl(0, 0%, 94%);
|
||||
$primary-neutral-dark-color: hsl(210, 29%, 29%);
|
||||
|
||||
$secondary-neutral-color: hsl(204, 64%, 44%);
|
||||
$secondary-neutral-light-color: hsl(0, 0%, 91%);
|
||||
$secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
|
||||
|
||||
$white-color: hsl(219.6, 20.8%, 98%);
|
||||
$black-color: hsl(0, 0%, 17%);
|
||||
$red-text-color: #eb2f06;
|
||||
$hovered-red-text-color: #ff4d4d;
|
||||
--blue: #3b69c4;
|
||||
--lightblue: #1a78b3;
|
||||
// darkened-deepblue is 10% darker than deepblue
|
||||
// This is explicitely declared, because the `darken` scss function
|
||||
// isn't called at compile-time somehow.
|
||||
--deepblue: #354a5f;
|
||||
--darkened-deepblue: #23313e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand colours.
|
||||
* Those are officially defined by their respective companies,
|
||||
* and so are more appropriate as scss variables.
|
||||
*/
|
||||
$faceblue: hsl(221, 44%, 41%);
|
||||
$twitblue: hsl(206, 82%, 63%);
|
||||
$discordblurple: #7289da;
|
||||
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
|
||||
$githubblack: rgb(22, 22, 20);
|
||||
|
||||
$shadow-color: rgb(223, 223, 223);
|
||||
|
||||
$background-button-color: hsl(0, 0%, 95%);
|
||||
|
||||
$deepblue: #354a5f;
|
||||
|
||||
@mixin shadow {
|
||||
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
|
||||
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-off-white);
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
@@ -30,12 +30,12 @@
|
||||
gap: 20px;
|
||||
|
||||
&:disabled {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
background-color: var(--bg-darkened-off-white);
|
||||
opacity: 65%;
|
||||
}
|
||||
|
||||
&.clickable:not(:disabled):hover {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
background-color: var(--bg-darkened-off-white);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
|
||||
@@ -11,7 +11,7 @@ ui-tab-group {
|
||||
.tab-headers {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-off-white);
|
||||
padding: 3px 12px 12px;
|
||||
column-gap: 20px;
|
||||
border-top-left-radius: 5px;
|
||||
@@ -25,23 +25,27 @@ ui-tab-group {
|
||||
background-color: unset;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-lightgrey);
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
|
||||
border-bottom: 4px solid var(--bg-lightgrey);
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
border-bottom-color: darken($primary-neutral-light-color, 20%);
|
||||
filter: brightness(85%);
|
||||
}
|
||||
|
||||
&.active:after {
|
||||
border-bottom-color: $primary-dark-color;
|
||||
border-bottom-color: var(--lightblue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,92 +2,75 @@
|
||||
@import "devices";
|
||||
|
||||
footer.bottom-links {
|
||||
>section>a {
|
||||
> section > a {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
text-align: center;
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (max-width: $small-devices) {
|
||||
margin-top: 0.6em;
|
||||
padding: 1.25em;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
background-color: var(--deepblue);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
gap: 1.25em;
|
||||
|
||||
>section {
|
||||
|
||||
> section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.8em;
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
color: var(--text-white);
|
||||
|
||||
&:hover {
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $white-color;
|
||||
color: var(--text-white);
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
border: none;
|
||||
border-top: 0.5px solid $white-color;
|
||||
border-top: var(--bg-white) 1px solid;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: $small-devices) {
|
||||
width: 90%;
|
||||
margin: 2em auto;
|
||||
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
section:first-of-type {
|
||||
margin: 0.6em 0;
|
||||
color: $white-color;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
box-shadow: $shadow-color 0 0 15px;
|
||||
background-color: var(--deepblue);
|
||||
|
||||
a {
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
padding: 0.8em;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: var(--text-white);
|
||||
|
||||
&:hover {
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $githubblack;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-14
@@ -16,7 +16,6 @@
|
||||
input[type="file"] {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
@@ -24,7 +23,7 @@
|
||||
color: black;
|
||||
|
||||
&:not(.link-like):not(:disabled):hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
background: var(--bg-lightgrey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@
|
||||
.ts-control {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
//background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
@@ -64,7 +63,7 @@
|
||||
textarea {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
//background-color: $background-button-color;
|
||||
padding: 7px;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
@@ -75,7 +74,7 @@
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
background-color: $background-button-color;
|
||||
//background-color: $background-button-color;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
@@ -83,14 +82,14 @@
|
||||
|
||||
a:not(.button) {
|
||||
text-decoration: none;
|
||||
color: $primary-dark-color;
|
||||
color: var(--lightblue);
|
||||
|
||||
&:hover {
|
||||
color: $primary-light-color;
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: $primary-color;
|
||||
color: var(--blue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +160,7 @@ form {
|
||||
|
||||
fieldset {
|
||||
margin-bottom: 1rem;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.row > label {
|
||||
@@ -762,16 +762,13 @@ form {
|
||||
// a submit input that should look like a regular <a>
|
||||
input[type="submit"], button {
|
||||
&.link-like {
|
||||
color: $primary-dark-color;
|
||||
color: var(--lightblue);
|
||||
&:hover {
|
||||
color: $primary-light-color;
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
&.link-red {
|
||||
color: $red-text-color;
|
||||
&:hover {
|
||||
color: $hovered-red-text-color;
|
||||
}
|
||||
color: var(--text-red);
|
||||
}
|
||||
font-weight: normal;
|
||||
font-size: 100%;
|
||||
|
||||
@@ -7,7 +7,7 @@ $background-color-hovered: #283747;
|
||||
|
||||
.header {
|
||||
box-sizing: border-box;
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
box-shadow: 3px 3px 3px 0 #dfdfdf;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
@@ -96,7 +96,7 @@ $background-color-hovered: #283747;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
width: 45px;
|
||||
height: 25px;
|
||||
padding: 0;
|
||||
@@ -212,7 +212,7 @@ $background-color-hovered: #283747;
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
}
|
||||
|
||||
>.options {
|
||||
@@ -298,7 +298,7 @@ $background-color-hovered: #283747;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
padding: 5px;
|
||||
background-color: $red-text-color;
|
||||
background-color: var(--text-red);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.markdown {
|
||||
--bg-lightgrey: rgb(240, 241, 245);
|
||||
--border-lightgrey: rgb(215, 216, 220);
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -28,11 +25,20 @@
|
||||
font-size: 14px;
|
||||
tab-size: 4;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-lightgrey);
|
||||
background-color: var(--bg-lightgrey);
|
||||
border: 1px solid var(--bg-lightgrey);
|
||||
background-color: var(--bg-off-white);
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some restyling rules for the cases where our own md css
|
||||
* conflicts with the easymde editor preview css
|
||||
*/
|
||||
&.editor-preview {
|
||||
pre {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
pre code {
|
||||
// code that is not inlined
|
||||
margin: 15px;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
$desktop-size: 500px;
|
||||
|
||||
nav.navbar {
|
||||
background-color: $primary-dark-color;
|
||||
background-color: var(--lightblue);
|
||||
margin: 1em;
|
||||
color: white;
|
||||
border-radius: 0.6em;
|
||||
|
||||
@@ -6,27 +6,21 @@
|
||||
margin: 30px;
|
||||
|
||||
button {
|
||||
background-color: $secondary-neutral-light-color;
|
||||
background-color: var(--bg-off-white);
|
||||
min-width: 37px;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($secondary-neutral-light-color, 10%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #fff;
|
||||
background-color: darken($secondary-neutral-light-color, 5%);
|
||||
background-color: var(--bg-lightgrey);
|
||||
}
|
||||
|
||||
&.active,
|
||||
&.active:hover {
|
||||
background-color: $primary-neutral-color;
|
||||
background-color: var(--deepblue);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:first-of-type,
|
||||
&:last-of-type {
|
||||
.pagination-arrow {
|
||||
// previous and next buttons
|
||||
&:disabled {
|
||||
// Hide the arrows when they are disabled, without
|
||||
|
||||
+65
-101
@@ -2,12 +2,21 @@
|
||||
@import "forms";
|
||||
@import "devices";
|
||||
|
||||
/*----- RESET -----*/
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
/*--------------------------------GENERAL------------------------------*/
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-family: sans-serif;
|
||||
background-color: var(--bg-white);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[aria-busy] {
|
||||
@@ -57,7 +66,7 @@ body {
|
||||
|
||||
.collapse-header {
|
||||
color: white;
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -109,6 +118,12 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
display: block;
|
||||
margin: .5em auto;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
/*--------------------------------HEADER-------------------------------*/
|
||||
|
||||
#info_boxes {
|
||||
@@ -141,7 +156,7 @@ body {
|
||||
}
|
||||
|
||||
#info_box {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
|
||||
&:before {
|
||||
content: "\f05a";
|
||||
@@ -150,11 +165,11 @@ body {
|
||||
}
|
||||
|
||||
#alert_box {
|
||||
background: $second-color;
|
||||
background: var(--bg-yellow);
|
||||
|
||||
&:before {
|
||||
content: "\f06a";
|
||||
color: $white-color;
|
||||
color: var(--text-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,10 +204,10 @@ body {
|
||||
border-radius: 5px;
|
||||
|
||||
&.btn-blue {
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: darken($deepblue, 10%);
|
||||
background-color: var(--darkened-deepblue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -215,7 +230,7 @@ body {
|
||||
&.btn-green {
|
||||
$bg-color: rgba(0, 210, 83, 0.4);
|
||||
background-color: $bg-color;
|
||||
color: $black-color;
|
||||
color: var(--text-black);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: darken($bg-color, 15%);
|
||||
@@ -272,8 +287,8 @@ body {
|
||||
/*--------------------------------CONTENT------------------------------*/
|
||||
#content {
|
||||
padding: 1.5em 3%;
|
||||
box-shadow: $shadow-color 0 5px 10px;
|
||||
background: $white-color;
|
||||
box-shadow: var(--bg-darkened-off-white) 0 5px 10px;
|
||||
background: var(--bg-white);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -343,7 +358,7 @@ body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
float: right;
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
@@ -351,7 +366,7 @@ body {
|
||||
a {
|
||||
padding: 7px;
|
||||
display: inline-block;
|
||||
color: $black-color;
|
||||
color: var(--text-black);
|
||||
font-weight: bold;
|
||||
margin: 2px;
|
||||
flex: 1;
|
||||
@@ -359,14 +374,14 @@ body {
|
||||
white-space: nowrap;
|
||||
|
||||
&.selected_tab {
|
||||
background: $primary-color;
|
||||
color: $white-color;
|
||||
background: var(--blue);
|
||||
color: var(--text-white);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $primary-color;
|
||||
color: $white-color;
|
||||
background: var(--blue);
|
||||
color: var(--text-white);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
@@ -398,28 +413,33 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Fontawesome icons */
|
||||
.fa-brands, .fa-link {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-facebook {
|
||||
color: $faceblue;
|
||||
}
|
||||
/* Fontawesome icons */
|
||||
.fa-brands, .fa-link {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.fa-discord {
|
||||
color: $discordblurple;
|
||||
}
|
||||
.fa-facebook {
|
||||
color: $faceblue;
|
||||
}
|
||||
|
||||
.fa-square-instagram::before, .fa-instagram::before {
|
||||
background: $instagradient;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.fa-discord {
|
||||
color: $discordblurple;
|
||||
}
|
||||
|
||||
.fa-bluesky, .fa-square-bluesky {
|
||||
color: #0f73ff;
|
||||
}
|
||||
.fa-square-instagram::before, .fa-instagram::before {
|
||||
background: $instagradient;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.fa-bluesky, .fa-square-bluesky {
|
||||
color: #0f73ff;
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $githubblack;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-devices) {
|
||||
@@ -481,16 +501,6 @@ dt {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.edit-bar {
|
||||
display: block;
|
||||
margin: 4px;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.important {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
@@ -503,7 +513,6 @@ table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
border-radius: 5px;
|
||||
-moz-border-radius: 5px;
|
||||
overflow: hidden;
|
||||
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
|
||||
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
|
||||
@@ -515,25 +524,21 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
padding: 5px 10px;
|
||||
|
||||
>input[type="checkbox"] {
|
||||
> input[type="checkbox"] {
|
||||
padding: unset;
|
||||
}
|
||||
|
||||
>ul {
|
||||
> ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
>input[type="checkbox"] {
|
||||
> input[type="checkbox"] {
|
||||
padding: unset;
|
||||
}
|
||||
}
|
||||
@@ -543,10 +548,6 @@ td {
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
>ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -556,23 +557,23 @@ thead td {
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
tbody>tr {
|
||||
tbody > tr {
|
||||
&:nth-child(even):not(.highlight) {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
}
|
||||
|
||||
&.clickable:hover {
|
||||
cursor: pointer;
|
||||
background: $secondary-neutral-light-color;
|
||||
background: var(--bg-lightgrey);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
color: $primary-dark-color;
|
||||
color: var(--lightblue);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -598,7 +599,7 @@ em {
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: $second-color;
|
||||
background: var(--bg-yellow);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -700,15 +701,15 @@ u,
|
||||
height: 18em;
|
||||
overflow: hidden;
|
||||
border: 1px solid black;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 1px 1px 5px $second-color;
|
||||
box-shadow: 1px 1px 5px var(--bg-yellow);
|
||||
}
|
||||
}
|
||||
|
||||
.matmat_user a {
|
||||
color: $primary-neutral-dark-color;
|
||||
color: var(--deepblue);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -723,7 +724,6 @@ u,
|
||||
display: block;
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
background: $white-color;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@@ -731,43 +731,7 @@ textarea {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.ui-dialog .ui-dialog-buttonpane {
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
width: 97%;
|
||||
}
|
||||
|
||||
#cash_summary_form label,
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.form_button {
|
||||
width: 150px;
|
||||
height: 120px;
|
||||
padding: 2px;
|
||||
display: inline-block;
|
||||
font-size: 0.8em;
|
||||
|
||||
span {
|
||||
width: 70px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 50px;
|
||||
max-height: 50px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
button {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
+11
-47
@@ -21,7 +21,6 @@ body {
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
box-shadow: none;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
|
||||
.alert {
|
||||
@@ -37,8 +36,7 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
>div,
|
||||
>form {
|
||||
form {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -49,67 +47,33 @@ body {
|
||||
max-width: 500px;
|
||||
margin-top: 20px;
|
||||
|
||||
>p,
|
||||
>div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
input[type="submit"] {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
||||
>label {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 500px) {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
max-width: 300px;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
>input,
|
||||
>p>input,
|
||||
>div>input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
|
||||
@media (min-width: 500px) {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
>.errorlist {
|
||||
.errorlist {
|
||||
color: red;
|
||||
text-align: center;
|
||||
margin: 10px 0 0 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
>.required>.helptext {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
>.required:last-of-type {
|
||||
box-sizing: border-box;
|
||||
div, fieldset {
|
||||
max-width: 300px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.captcha {
|
||||
box-sizing: border-box;
|
||||
|
||||
>label {
|
||||
width: 100%;
|
||||
fieldset {
|
||||
margin-bottom: unset
|
||||
}
|
||||
|
||||
>img {
|
||||
width: 70px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
>input {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ main {
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-white);
|
||||
|
||||
> span {
|
||||
font-size: small;
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
|
||||
&:enabled>.fa {
|
||||
background-color: #354a5f;
|
||||
background-color: var(--deepblue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
<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') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/tooltips.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{%- block additional_css -%}
|
||||
<link rel="stylesheet" href="{{ static('user/login.scss') }}">
|
||||
{%- endblock -%}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Login{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block info_boxes %}
|
||||
{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="title">{% trans %}Terms of Service{% endtrans %}</h1>
|
||||
|
||||
<form method="post" id="login-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="alert alert-yellow">
|
||||
{% trans trimmed %}
|
||||
To continue using our services,
|
||||
please read and approve the AE website's terms of service
|
||||
{% endtrans %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.cgu_approved_at.errors }}
|
||||
{{ form.cgu_approved_at }}
|
||||
{{ form.cgu_approved_at.label_tag() }}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="submit" class="btn btn-blue">
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,5 +29,13 @@
|
||||
</span>
|
||||
{{ form.show_my_stats.errors }}
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
{{ form.show_my_timetable }}
|
||||
{{ form.show_my_timetable.label_tag() }}
|
||||
<span class="helptext">
|
||||
{{ form.show_my_timetable.help_text }}
|
||||
</span>
|
||||
{{ form.show_my_timetable.errors }}
|
||||
</fieldset>
|
||||
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
|
||||
</form>
|
||||
@@ -35,28 +35,17 @@
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
|
||||
{{ form.username }}
|
||||
{{ form.username.errors }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.password.name }}">{{ form.password.label }}</label>
|
||||
{{ form.password }}
|
||||
{{ form.password.errors }}
|
||||
</div>
|
||||
{{ form }}
|
||||
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="submit" value="{% trans %}Login{% endtrans %}">
|
||||
<input type="submit" class="btn btn-blue" value="{% trans %}Login{% endtrans %}">
|
||||
|
||||
|
||||
{# Assumes you setup the password_reset view in your URLconf #}
|
||||
<p>
|
||||
<a href="{{ url('core:password_reset') }}">{% trans %}Lost password?{% endtrans %}</a>
|
||||
|
||||
<a href="{{ url('core:register') }}">{% trans %}Create account{% endtrans %}</a>
|
||||
</p>
|
||||
<div>
|
||||
<a href="{{ url("core:password_reset") }}">{% trans %}Lost password?{% endtrans %}</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url("core:register") }}">{% trans %}Create account{% endtrans %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
@click.prevent="{{ page }}--"
|
||||
:disabled="{{ page }} <= 1"
|
||||
@keyup.right.window="{{ page }} = Math.min({{ nb_pages }}, {{ page }} + 1)"
|
||||
class="pagination-arrow"
|
||||
>
|
||||
<i class="fa fa-caret-left"></i>
|
||||
</button>
|
||||
@@ -112,6 +113,7 @@
|
||||
@click.prevent="{{ page }}++"
|
||||
:disabled="{{ page }} >= {{ nb_pages }}"
|
||||
@keyup.left.window="{{ page }} = Math.max(1, {{ page }} - 1)"
|
||||
class="pagination-arrow"
|
||||
>
|
||||
<i class="fa fa-caret-right"></i>
|
||||
</button>
|
||||
@@ -163,12 +165,14 @@
|
||||
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>
|
||||
<button class="pagination-arrow">
|
||||
<i class="fa fa-caret-left"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button disabled="disabled"><i class="fa fa-caret-left"></i></button>
|
||||
<button disabled="disabled" class="pagination-arrow">
|
||||
<i class="fa fa-caret-left"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% for i in paginator.get_elided_page_range(current_page.number) %}
|
||||
{% if i == current_page.number %}
|
||||
@@ -201,12 +205,14 @@
|
||||
{%- else -%}
|
||||
href="?{{querystring(request, page=current_page.next_page_number())}}"
|
||||
{%- endif -%}
|
||||
><button>
|
||||
><button class="pagination-arrow">
|
||||
<i class="fa fa-caret-right"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button disabled="disabled"><i class="fa fa-caret-right"></i></button>
|
||||
<button disabled="disabled" class="pagination-arrow">
|
||||
<i class="fa fa-caret-right"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -18,7 +18,17 @@
|
||||
<form action="{{ url('core:register') }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% render_honeypot_field %}
|
||||
{{ form.as_p() }}
|
||||
<input type="submit" value="{% trans %}Register{% endtrans %}" />
|
||||
{% for field in form %}
|
||||
{% if field.name not in ["cgu_approved_at", "captcha"] %}
|
||||
<div>{{ field.as_field_group() }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="captcha">{{ form.captcha.as_field_group() }}</div>
|
||||
<div class="form-group">
|
||||
{{ form.cgu_approved_at.errors }}
|
||||
{{ form.cgu_approved_at }}
|
||||
{{ form.cgu_approved_at.label_tag() }}
|
||||
</div>
|
||||
<input type="submit" class="btn btn-blue" value="{% trans %}Register{% endtrans %}" />
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -79,15 +79,5 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if
|
||||
profile.mailing_subscriptions.exists()
|
||||
and (profile.id == user.id or user.is_root or user.is_com_admin)
|
||||
%}
|
||||
<h4>{% trans %}Subscribed mailing lists{% endtrans %}</h4>
|
||||
{% for sub in profile.mailing_subscriptions.all() %}
|
||||
<p>{{ sub.mailing.email }} <a href="{{ url('club:mailing_subscription_delete', mailing_subscription_id=sub.id) }}">{% trans %}Unsubscribe{% endtrans %}</a></p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
{% for sum in purchase_sums %}
|
||||
<div>
|
||||
<span>{{ sum["counter__name"] }}</span>
|
||||
<span>{{ sum["total"] }} €</span>
|
||||
<span>{{ "%.2f"|format(sum["total"]) }} €</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div><b>Total : </b><b>{{ total_purchases }} €</b></div>
|
||||
<div><b>Total : </b><b>{{ "%.2f"|format(total_purchases) }} €</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
<li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_populate_more(settings):
|
||||
"""Just check that populate more doesn't crash"""
|
||||
settings.DEBUG = True
|
||||
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
|
||||
call_command("populate_more", "--nb-users", "50")
|
||||
+38
-2
@@ -27,6 +27,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.mail import EmailMessage
|
||||
from django.test import Client, RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.views.generic import View
|
||||
from django.views.generic.base import ContextMixin
|
||||
from model_bakery import baker
|
||||
@@ -55,6 +56,7 @@ class TestUserRegistration:
|
||||
"password2": "plop",
|
||||
"captcha_0": "dummy-value",
|
||||
"captcha_1": "PASSED",
|
||||
"cgu_approved_at": now(),
|
||||
}
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -92,6 +94,10 @@ class TestUserRegistration:
|
||||
({"first_name": ""}, "Ce champ est obligatoire."),
|
||||
({"last_name": ""}, "Ce champ est obligatoire."),
|
||||
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
|
||||
(
|
||||
{"cgu_approved_at": False},
|
||||
"Vous devez approuver les conditions générales d'utilisation",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_register_user_form_fail(
|
||||
@@ -150,7 +156,7 @@ class TestUserRegistration:
|
||||
class TestUserLogin:
|
||||
@pytest.fixture()
|
||||
def user(self) -> User:
|
||||
return baker.make(User, password=make_password("plop"))
|
||||
return baker.make(User, password=make_password("plop"), cgu_approved_at=now())
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier_getter",
|
||||
@@ -191,10 +197,40 @@ class TestUserLogin:
|
||||
reverse("core:login"),
|
||||
{"username": identifier_getter(user), "password": "plop"},
|
||||
)
|
||||
assertRedirects(response, reverse("core:index"))
|
||||
assertRedirects(response, settings.LOGIN_REDIRECT_URL)
|
||||
assert response.wsgi_request.user == user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCGU:
|
||||
def test_cgu_approval(self, client: Client):
|
||||
user = baker.make(User, password=make_password("plop"), cgu_approved_at=None)
|
||||
user_url = user.get_absolute_url()
|
||||
res = client.post(
|
||||
reverse("core:login"),
|
||||
{"username": user.username, "password": "plop", "next": user_url},
|
||||
)
|
||||
assertRedirects(res, reverse("core:approve_cgu", query={"next": user_url}))
|
||||
res = client.post(
|
||||
reverse("core:approve_cgu"), {"cgu_approved_at": now(), "next": user_url}
|
||||
)
|
||||
assertRedirects(res, user_url)
|
||||
user.refresh_from_db()
|
||||
assert user.cgu_approved_at is not None
|
||||
|
||||
def test_access_cgu_when_already_approved(self, client: Client):
|
||||
url = reverse("core:approve_cgu")
|
||||
|
||||
res = client.get(url)
|
||||
assertRedirects(res, reverse("core:login"))
|
||||
|
||||
client.force_login(baker.make(User, cgu_approved_at=now()))
|
||||
res = client.get(url)
|
||||
assertRedirects(res, settings.LOGIN_REDIRECT_URL)
|
||||
res = client.post(url, {"cgu_approved_at": now()})
|
||||
assertRedirects(res, settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("md", "html"),
|
||||
[
|
||||
|
||||
@@ -31,6 +31,7 @@ from core.converters import (
|
||||
TwoDigitMonthConverter,
|
||||
)
|
||||
from core.views import (
|
||||
CGUApprovalView,
|
||||
FileDeleteView,
|
||||
FileEditPropView,
|
||||
FileEditView,
|
||||
@@ -83,6 +84,7 @@ from core.views import (
|
||||
notification,
|
||||
send_file,
|
||||
)
|
||||
from timetable.views import UserTimetableView
|
||||
|
||||
register_converter(FourDigitYearConverter, "yyyy")
|
||||
register_converter(TwoDigitMonthConverter, "mm")
|
||||
@@ -125,6 +127,7 @@ urlpatterns = [
|
||||
name="password_reset_complete",
|
||||
),
|
||||
path("register/", UserCreationView.as_view(), name="register"),
|
||||
path("cgu/", CGUApprovalView.as_view(), name="approve_cgu"),
|
||||
# Group handling
|
||||
path("group/", GroupListView.as_view(), name="group_list"),
|
||||
path("group/new/", GroupCreateView.as_view(), name="group_new"),
|
||||
@@ -182,6 +185,11 @@ urlpatterns = [
|
||||
name="user_account_detail",
|
||||
),
|
||||
path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"),
|
||||
path(
|
||||
"user/<int:user_id>/edt/",
|
||||
UserTimetableView.as_view(),
|
||||
name="user_timetable",
|
||||
),
|
||||
path(
|
||||
"user/<int:user_id>/gift/create/",
|
||||
GiftCreateView.as_view(),
|
||||
|
||||
@@ -12,12 +12,15 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import PIL
|
||||
from django.conf import settings
|
||||
@@ -25,6 +28,15 @@ from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import localdate
|
||||
from PIL.Image import Image, Resampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _hashlib import HASH
|
||||
from collections.abc import Buffer, Mapping, Sequence
|
||||
from typing import Any, Callable, Final
|
||||
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
@@ -190,3 +202,30 @@ def get_client_ip(request: HttpRequest) -> str | None:
|
||||
return ip
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def hmac_hexdigest(
|
||||
key: str | bytes,
|
||||
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
|
||||
digest: str | Callable[[Buffer], HASH] = "sha512",
|
||||
) -> str:
|
||||
"""Return the hexdigest of the signature of the given data.
|
||||
|
||||
Args:
|
||||
key: the HMAC key used for the signature
|
||||
data: the data to sign
|
||||
digest: a PEP247 hashing algorithm (by default, sha512)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
data = {
|
||||
"foo": 5,
|
||||
"bar": "somevalue",
|
||||
}
|
||||
hmac_key = secrets.token_hex(64)
|
||||
signature = hmac_hexdigest(hmac_key, data, "sha256")
|
||||
```
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
key = key.encode()
|
||||
return hmac.digest(key, urlencode(data).encode(), digest).hex()
|
||||
+12
-10
@@ -21,7 +21,7 @@ from wsgiref.util import FileWrapper
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
@@ -233,6 +233,12 @@ class FileEditPropForm(forms.ModelForm):
|
||||
|
||||
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
||||
|
||||
def clean_parent(self):
|
||||
parent = self.cleaned_data["parent"]
|
||||
if not self.instance.owner.can_edit(parent):
|
||||
raise ValidationError(_("You cannot move this file there"))
|
||||
return parent
|
||||
|
||||
|
||||
class FileEditPropView(CanEditPropMixin, UpdateView):
|
||||
model = SithFile
|
||||
@@ -253,10 +259,7 @@ class FileEditPropView(CanEditPropMixin, UpdateView):
|
||||
return ret
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
"core:file_detail",
|
||||
kwargs={"file_id": self.object.id},
|
||||
)
|
||||
return reverse("core:file_detail", kwargs={"file_id": self.object.id})
|
||||
|
||||
|
||||
class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
@@ -269,7 +272,7 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
form_class = AddFilesForm
|
||||
|
||||
@staticmethod
|
||||
def handle_clipboard(request, obj):
|
||||
def handle_clipboard(request, obj: SithFile):
|
||||
"""Handle the clipboard in the view.
|
||||
|
||||
This method can fail, since it does not catch the exceptions coming from
|
||||
@@ -283,10 +286,9 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
where you want to paste the clipboard
|
||||
"""
|
||||
if "delete" in request.POST:
|
||||
for f_id in request.POST.getlist("file_list"):
|
||||
file = SithFile.objects.filter(id=f_id).first()
|
||||
if file:
|
||||
file.delete()
|
||||
file_ids = request.POST.getlist("file_list")
|
||||
for file in obj.children.filter(id__in=file_ids):
|
||||
file.delete()
|
||||
if "clear" in request.POST:
|
||||
request.session["clipboard"] = []
|
||||
if "cut" in request.POST:
|
||||
|
||||
+73
-7
@@ -30,9 +30,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.staticfiles.management.commands.collectstatic import (
|
||||
staticfiles_storage,
|
||||
)
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.forms import (
|
||||
@@ -42,6 +40,9 @@ from django.forms import (
|
||||
TextInput,
|
||||
Widget,
|
||||
)
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
@@ -108,6 +109,43 @@ class FutureDateTimeField(forms.DateTimeField):
|
||||
return {"min": widget.format_value(now())}
|
||||
|
||||
|
||||
class CGUApprovalField(forms.BooleanField):
|
||||
"""Form field with a checkbox to approve the CGUs.
|
||||
|
||||
The checkbox must be checked to be valid.
|
||||
If valid, then the value of the field is the current timestamp.
|
||||
"""
|
||||
|
||||
default_error_messages = {"required": _("You must approve the terms of service.")}
|
||||
__label = None
|
||||
|
||||
def __init__(self, *, label_suffix: str | None = "", **kwargs):
|
||||
# Because the core app of the sith is so huge,
|
||||
# and because we require a url from the latter,
|
||||
# putting the reverse into the __init__ will result in it
|
||||
# being evaluated at server startup time (even with reverse_lazy).
|
||||
# This will result in a circular import.
|
||||
# Thus, we must keep the label in its own property and force it to be lazy.
|
||||
kwargs["label"] = lazy(self.get_label, str)
|
||||
kwargs["required"] = True
|
||||
super().__init__(label_suffix=label_suffix, **kwargs)
|
||||
|
||||
def to_python(self, value):
|
||||
return now() if super().to_python(value) else None
|
||||
|
||||
def get_label(self):
|
||||
if not self.__label:
|
||||
url = reverse("core:page", kwargs={"page_name": settings.SITH_CGU_PAGE})
|
||||
self.__label = mark_safe(
|
||||
_(
|
||||
"I have read and I approve the "
|
||||
'<a href="%(url)s" target="_blank">Terms of Service</a>'
|
||||
)
|
||||
% {"url": url}
|
||||
)
|
||||
return self.__label
|
||||
|
||||
|
||||
# Forms
|
||||
|
||||
|
||||
@@ -146,8 +184,28 @@ class RegisteringForm(UserCreationForm):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("first_name", "last_name", "email")
|
||||
field_classes = {"email": AntiSpamEmailField}
|
||||
fields = ("first_name", "last_name", "email", "cgu_approved_at")
|
||||
field_classes = {
|
||||
"email": AntiSpamEmailField,
|
||||
"cgu_approved_at": CGUApprovalField,
|
||||
}
|
||||
|
||||
|
||||
class CGUApprovalForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["cgu_approved_at"]
|
||||
field_classes = {"cgu_approved_at": CGUApprovalField}
|
||||
|
||||
def __init__(self, *args, instance: User | None = None, **kwargs):
|
||||
if instance:
|
||||
# If this form is displayed,
|
||||
# then we want the user to explicitly check the button.
|
||||
# So we pretend cgu were never approved (even if they were).
|
||||
# If we didn't do that, the button would be initially checked,
|
||||
# even if the approval was done before the last CGU version.
|
||||
instance.cgu_approved_at = False
|
||||
super().__init__(*args, instance=instance, **kwargs)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
@@ -278,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm):
|
||||
|
||||
__preferences_fields = forms.fields_for_model(
|
||||
Preferences,
|
||||
["show_my_stats"],
|
||||
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
|
||||
["show_my_stats", "show_my_timetable"],
|
||||
widgets={
|
||||
"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||
"show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||
},
|
||||
)
|
||||
show_my_stats = __preferences_fields["show_my_stats"]
|
||||
show_my_timetable = __preferences_fields["show_my_timetable"]
|
||||
|
||||
def __init__(
|
||||
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
|
||||
@@ -289,12 +351,16 @@ class UserVisibilityForm(forms.ModelForm):
|
||||
if instance:
|
||||
initial = initial or {}
|
||||
initial["show_my_stats"] = instance.preferences.show_my_stats
|
||||
initial["show_my_timetable"] = instance.preferences.show_my_timetable
|
||||
super().__init__(*args, initial=initial, instance=instance, **kwargs)
|
||||
|
||||
def save(self, commit=True) -> User: # noqa: FBT002
|
||||
instance = super().save(commit=commit)
|
||||
if commit:
|
||||
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
|
||||
instance.preferences.show_my_timetable = self.cleaned_data[
|
||||
"show_my_timetable"
|
||||
]
|
||||
instance.preferences.save()
|
||||
return instance
|
||||
|
||||
|
||||
+16
-2
@@ -1,6 +1,14 @@
|
||||
import copy
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
LiteralString,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
Unpack,
|
||||
)
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.template.loader import render_to_string
|
||||
@@ -12,11 +20,17 @@ if TYPE_CHECKING:
|
||||
from django.utils.safestring import SafeString
|
||||
|
||||
|
||||
class TabItem(TypedDict):
|
||||
url: str
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
class TabedViewMixin(View):
|
||||
"""Basic functions for displaying tabs in the template."""
|
||||
|
||||
current_tab: ClassVar[str | None] = None
|
||||
list_of_tabs: ClassVar[list | None] = None
|
||||
list_of_tabs: ClassVar[list[TabItem] | None] = None
|
||||
tabs_title: ClassVar[str | None] = None
|
||||
|
||||
def get_tabs_title(self):
|
||||
|
||||
+35
-6
@@ -27,12 +27,12 @@ from datetime import timedelta
|
||||
# This file contains all the views that concern the user model
|
||||
from operator import itemgetter
|
||||
from smtplib import SMTPException
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login, views
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
|
||||
from django.contrib.auth.forms import SetPasswordForm
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
@@ -60,6 +60,7 @@ from honeypot.decorators import check_honeypot
|
||||
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
|
||||
from core.models import Gift, Preferences, User
|
||||
from core.views.forms import (
|
||||
CGUApprovalForm,
|
||||
GiftForm,
|
||||
LoginForm,
|
||||
RegisteringForm,
|
||||
@@ -71,6 +72,7 @@ from core.views.forms import (
|
||||
from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin
|
||||
from counter.models import Refilling, Selling
|
||||
from eboutic.models import Invoice
|
||||
from sith import settings
|
||||
from trombi.views import UserTrombiForm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -82,9 +84,16 @@ class SithLoginView(views.LoginView):
|
||||
|
||||
template_name = "core/login.jinja"
|
||||
authentication_form = LoginForm
|
||||
form_class = PasswordChangeForm
|
||||
redirect_authenticated_user = True
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
redirect_to = self.get_redirect_url()
|
||||
default_url = self.get_default_redirect_url()
|
||||
if not self.request.user.approved_current_cgu:
|
||||
query = {"next": redirect_to} if redirect_to else {}
|
||||
return reverse("core:approve_cgu", query=query)
|
||||
return redirect_to or default_url
|
||||
|
||||
|
||||
class SithPasswordChangeView(views.PasswordChangeView):
|
||||
"""Allows a user to change its password."""
|
||||
@@ -188,6 +197,27 @@ class UserCreationView(FormView):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class CGUApprovalView(views.RedirectURLMixin, UpdateView):
|
||||
form_class = CGUApprovalForm
|
||||
next_page = settings.LOGIN_REDIRECT_URL
|
||||
template_name = "core/cgu_approve.jinja"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if self.request.user.is_anonymous:
|
||||
return redirect("core:login")
|
||||
if self.request.user.approved_current_cgu:
|
||||
return redirect(self.get_success_url())
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_object(self, *args, **kwargs):
|
||||
return self.request.user
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict[str, Any]:
|
||||
return super().get_context_data(**kwargs) | {
|
||||
self.redirect_field_name: self.get_redirect_url()
|
||||
}
|
||||
|
||||
|
||||
class UserMeRedirect(LoginRequiredMixin, RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
if remaining := kwargs.get("remaining_path"):
|
||||
@@ -383,7 +413,7 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
|
||||
kwargs["perm_time"] = list(
|
||||
self.object.permanencies.filter(end__isnull=False, counter__type="BAR")
|
||||
self.object.permanencies.filter(end__isnull=False)
|
||||
.values("counter", "counter__name")
|
||||
.annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0)))
|
||||
.order_by("-total")
|
||||
@@ -392,8 +422,7 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
|
||||
[perm["total"] for perm in kwargs["perm_time"]], start=timedelta(seconds=0)
|
||||
)
|
||||
kwargs["purchase_sums"] = list(
|
||||
self.object.customer.buyings.filter(counter__type="BAR")
|
||||
.values("counter", "counter__name")
|
||||
self.object.customer.buyings.values("counter", "counter__name")
|
||||
.annotate(total=Sum(F("unit_price") * F("quantity")))
|
||||
.order_by("-total")
|
||||
)
|
||||
|
||||
+7
-1
@@ -130,8 +130,14 @@ class GetUserForm(forms.Form):
|
||||
elif cleaned_data["id"]:
|
||||
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||
|
||||
if customer is None or not customer.can_buy:
|
||||
if customer is None:
|
||||
raise forms.ValidationError(_("User not found"))
|
||||
if not customer.can_buy:
|
||||
raise forms.ValidationError(_("This user cannot buy items on counters."))
|
||||
if customer.user.is_banned_counter:
|
||||
raise forms.ValidationError(
|
||||
_("This user is banned from AE places. Take him/her out if you can.")
|
||||
)
|
||||
cleaned_data["user_id"] = customer.user_id
|
||||
cleaned_data["user"] = customer.user
|
||||
return cleaned_data
|
||||
|
||||
@@ -647,13 +647,6 @@ class Counter(models.Model):
|
||||
or user in self.sellers.all()
|
||||
)
|
||||
|
||||
def gen_token(self) -> None:
|
||||
"""Generate a new token for this counter."""
|
||||
self.token = "".join(
|
||||
random.choice(string.ascii_letters + string.digits) for _ in range(30)
|
||||
)
|
||||
self.save()
|
||||
|
||||
@cached_property
|
||||
def barmen_list(self) -> list[User]:
|
||||
"""Returns the barman list as list of User."""
|
||||
@@ -661,10 +654,6 @@ class Counter(models.Model):
|
||||
p.user for p in self.permanencies.filter(end=None).select_related("user")
|
||||
]
|
||||
|
||||
def get_random_barman(self) -> User:
|
||||
"""Return a random user being currently a barman."""
|
||||
return random.choice(self.barmen_list)
|
||||
|
||||
def update_activity(self) -> None:
|
||||
"""Update the barman activity to prevent timeout."""
|
||||
self.permanencies.filter(end=None).update(activity=timezone.now())
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
padding: 0.5em;
|
||||
margin: 0.2em;
|
||||
height: 100%;
|
||||
background: $secondary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
|
||||
img {
|
||||
max-width: 70%;
|
||||
|
||||
@@ -83,8 +83,17 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert-yellow">
|
||||
<div class="alert-main">
|
||||
{%- trans trimmed -%}
|
||||
You can see product details.
|
||||
However, you cannot edit it, thus you won't be able
|
||||
to submit this form.
|
||||
{%- endtrans -%}
|
||||
</div>
|
||||
</div>
|
||||
{% if object %}
|
||||
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
||||
<h2>{{ object }}</h2>
|
||||
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
|
||||
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
|
||||
{% else %}
|
||||
@@ -127,19 +136,17 @@
|
||||
{{ form.price_formset.management_form }}
|
||||
<div x-ref="formContainer">
|
||||
{%- for form in form.price_formset.forms -%}
|
||||
<div>
|
||||
{{ price_form(form) }}
|
||||
</div>
|
||||
<div>{{ price_form(form) }}</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
<template x-ref="formTemplate">
|
||||
<div>
|
||||
{{ price_form(form.price_formset.empty_form) }}
|
||||
</div>
|
||||
<div>{{ price_form(form.price_formset.empty_form) }}</div>
|
||||
</template>
|
||||
<button class="btn btn-grey" @click.prevent="addForm()">
|
||||
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
|
||||
</button>
|
||||
{% if user.has_perm("counter.change_product") %}
|
||||
<button class="btn btn-grey" @click.prevent="addForm()">
|
||||
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<br />
|
||||
@@ -165,11 +172,16 @@
|
||||
<template x-ref="formTemplate">
|
||||
{{ action_form(form.action_formset.empty_form) }}
|
||||
</template>
|
||||
<button @click.prevent="addForm()" class="btn btn-grey">
|
||||
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
||||
</button>
|
||||
{% if user.has_perm("counter.change_product") %}
|
||||
<button @click.prevent="addForm()" class="btn btn-grey">
|
||||
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
|
||||
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
|
||||
{% if user.has_perm("counter.change_product") %}
|
||||
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -80,9 +80,11 @@
|
||||
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
|
||||
|
||||
<div class="row margin-bottom">
|
||||
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
|
||||
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
|
||||
</a>
|
||||
{% if user.has_perm("counter.add_product") %}
|
||||
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
|
||||
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button
|
||||
class="btn btn-blue"
|
||||
@click="downloadCsv()"
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
|
||||
{% if user.has_perm("counter.add_producttype") %}
|
||||
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue margin-bottom">
|
||||
{% trans %}New product type{% endtrans %}
|
||||
<i class="fa fa-plus"></i>
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if product_types %}
|
||||
<aside>
|
||||
<p>
|
||||
|
||||
@@ -21,7 +21,8 @@ import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, make_password
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.messages import DEFAULT_LEVELS, get_messages
|
||||
from django.shortcuts import resolve_url
|
||||
from django.test import Client, TestCase
|
||||
@@ -65,8 +66,9 @@ class TestFullClickBase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.customer = subscriber_user.make()
|
||||
cls.barmen = subscriber_user.make(password=make_password("plop"))
|
||||
cls.board_admin = board_user.make(password=make_password("plop"))
|
||||
password = make_password("plop")
|
||||
cls.barmen = subscriber_user.make(password=password)
|
||||
cls.board_admin = board_user.make(password=password)
|
||||
cls.club_admin = subscriber_user.make()
|
||||
cls.root = baker.make(User, is_superuser=True)
|
||||
cls.subscriber = subscriber_user.make()
|
||||
|
||||
+6
-2
@@ -6,8 +6,10 @@ from django.urls import resolve
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpRequest
|
||||
|
||||
from counter.models import Counter
|
||||
|
||||
def is_logged_in_counter(request: HttpRequest) -> bool:
|
||||
|
||||
def is_logged_in_counter(request: HttpRequest, counter: Counter | None = None) -> bool:
|
||||
"""Check if the request is sent from a device logged to a counter.
|
||||
|
||||
The request must also be sent within the frame of a counter's activity.
|
||||
@@ -30,4 +32,6 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
|
||||
if not referer_ok and request.resolver_match.app_name != "counter":
|
||||
return False
|
||||
|
||||
return bool(request.barmen)
|
||||
if counter is None:
|
||||
return bool(request.barmen)
|
||||
return bool(request.barmen) and request.barmen.issubset(set(counter.barmen_list))
|
||||
+61
-58
@@ -48,7 +48,7 @@ from counter.models import (
|
||||
Selling,
|
||||
)
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
||||
from counter.views.mixins import CounterAdminTabsMixin
|
||||
|
||||
|
||||
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
|
||||
@@ -84,17 +84,18 @@ class CounterEditView(
|
||||
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
|
||||
|
||||
|
||||
class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
"""Edit a counter's main informations (for the counter's admin)."""
|
||||
class CounterEditPropView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""Edit a counter's main infos."""
|
||||
|
||||
model = Counter
|
||||
form_class = modelform_factory(Counter, fields=["name", "club", "type"])
|
||||
pk_url_kwarg = "counter_id"
|
||||
template_name = "core/edit.jinja"
|
||||
current_tab = "counters"
|
||||
permission_required = "counter.change_counter"
|
||||
|
||||
|
||||
class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||
"""Create a counter (for the admins)."""
|
||||
|
||||
model = Counter
|
||||
@@ -105,9 +106,10 @@ class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
)
|
||||
template_name = "core/create.jinja"
|
||||
current_tab = "counters"
|
||||
permission_required = "counter.add_counter"
|
||||
|
||||
|
||||
class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
|
||||
class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView):
|
||||
"""Delete a counter (for the admins)."""
|
||||
|
||||
model = Counter
|
||||
@@ -115,30 +117,33 @@ class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("counter:admin_list")
|
||||
current_tab = "counters"
|
||||
permission_required = "counter.delete_counter"
|
||||
|
||||
|
||||
# Product management
|
||||
|
||||
|
||||
class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
class ProductTypeListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||
"""A list view for the admins."""
|
||||
|
||||
model = ProductType
|
||||
template_name = "counter/product_type_list.jinja"
|
||||
current_tab = "product_types"
|
||||
context_object_name = "product_types"
|
||||
permission_required = "counter.view_producttype"
|
||||
|
||||
|
||||
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
class ProductTypeCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||
"""A create view for the admins."""
|
||||
|
||||
model = ProductType
|
||||
fields = ["name", "description", "comment", "icon"]
|
||||
template_name = "core/create.jinja"
|
||||
current_tab = "products"
|
||||
permission_required = "counter.add_producttype"
|
||||
|
||||
|
||||
class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""An edit view for the admins."""
|
||||
|
||||
model = ProductType
|
||||
@@ -146,23 +151,26 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
fields = ["name", "description", "comment", "icon"]
|
||||
pk_url_kwarg = "type_id"
|
||||
current_tab = "products"
|
||||
permission_required = "counter.change_producttype"
|
||||
|
||||
|
||||
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
||||
class ProductListView(CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView):
|
||||
current_tab = "products"
|
||||
template_name = "counter/product_list.jinja"
|
||||
permission_required = "counter.view_product"
|
||||
|
||||
|
||||
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
class ProductCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||
"""A create view for the admins."""
|
||||
|
||||
model = Product
|
||||
form_class = ProductForm
|
||||
template_name = "counter/product_form.jinja"
|
||||
current_tab = "products"
|
||||
permission_required = "counter.add_product"
|
||||
|
||||
|
||||
class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""An edit view for the admins."""
|
||||
|
||||
model = Product
|
||||
@@ -170,6 +178,13 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
pk_url_kwarg = "product_id"
|
||||
template_name = "counter/product_form.jinja"
|
||||
current_tab = "products"
|
||||
permission_required = "counter.change_product"
|
||||
|
||||
def has_permission(self):
|
||||
return self.request.user.has_perm("counter.change_product") or (
|
||||
self.request.method == "GET"
|
||||
and self.request.user.has_perm("counter.view_product")
|
||||
)
|
||||
|
||||
|
||||
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||
@@ -291,54 +306,41 @@ class ReturnableProductDeleteView(
|
||||
}
|
||||
|
||||
|
||||
class RefillingDeleteView(DeleteView):
|
||||
class CounterTransactionDeletionView(DeleteView):
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().select_related("counter", "customer")
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.object: Selling | Refilling = self.get_object()
|
||||
if timezone.now() - self.object.date <= timedelta(
|
||||
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||
) and is_logged_in_counter(request, counter=self.object.counter):
|
||||
self.success_url = reverse(
|
||||
"counter:details", kwargs={"counter_id": self.object.counter_id}
|
||||
)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
elif self.object.is_owned_by(request.user):
|
||||
self.success_url = reverse(
|
||||
"core:user_account", kwargs={"user_id": self.object.customer.user_id}
|
||||
)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
class RefillingDeleteView(CounterTransactionDeletionView):
|
||||
"""Delete a refilling (for the admins)."""
|
||||
|
||||
model = Refilling
|
||||
pk_url_kwarg = "refilling_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
|
||||
self.object = self.get_object()
|
||||
if timezone.now() - self.object.date <= timedelta(
|
||||
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||
) and is_logged_in_counter(request):
|
||||
self.success_url = reverse(
|
||||
"counter:details", kwargs={"counter_id": self.object.counter.id}
|
||||
)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
elif self.object.is_owned_by(request.user):
|
||||
self.success_url = reverse(
|
||||
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
|
||||
)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
class SellingDeleteView(DeleteView):
|
||||
class SellingDeleteView(CounterTransactionDeletionView):
|
||||
"""Delete a selling (for the admins)."""
|
||||
|
||||
model = Selling
|
||||
pk_url_kwarg = "selling_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
|
||||
self.object = self.get_object()
|
||||
if timezone.now() - self.object.date <= timedelta(
|
||||
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||
) and is_logged_in_counter(request):
|
||||
self.success_url = reverse(
|
||||
"counter:details", kwargs={"counter_id": self.object.counter.id}
|
||||
)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
elif self.object.is_owned_by(request.user):
|
||||
self.success_url = reverse(
|
||||
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
|
||||
)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
class CounterStatView(PermissionRequiredMixin, DetailView):
|
||||
@@ -376,30 +378,31 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
|
||||
return kwargs
|
||||
|
||||
|
||||
class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
class CounterRefillingListView(
|
||||
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
|
||||
):
|
||||
"""List of refillings on a counter."""
|
||||
|
||||
model = Refilling
|
||||
template_name = "counter/refilling_list.jinja"
|
||||
current_tab = "counters"
|
||||
paginate_by = 30
|
||||
permission_required = "counter.view_refilling"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"])
|
||||
self.queryset = Refilling.objects.filter(counter__id=self.counter.id)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
def get_queryset(self):
|
||||
self.counter = get_object_or_404(Counter, pk=self.kwargs["counter_id"])
|
||||
return Refilling.objects.filter(counter=self.counter).order_by("-date")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["counter"] = self.counter
|
||||
return kwargs
|
||||
return super().get_context_data(**kwargs) | {"counter": self.counter}
|
||||
|
||||
|
||||
class RefoundAccountView(UserPassesTestMixin, FormView):
|
||||
class RefoundAccountView(UserPassesTestMixin, SuccessMessageMixin, FormView):
|
||||
"""Create a selling with the same amount as the current user money."""
|
||||
|
||||
template_name = "counter/refound_account.jinja"
|
||||
form_class = CloseCustomerAccountForm
|
||||
success_message = _("%(user)s's AE account has been emptied")
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_root or self.request.user.is_in_group(
|
||||
|
||||
@@ -242,15 +242,11 @@ class RefillingCreateView(FragmentMixin, CreateView):
|
||||
if not self.customer.can_buy:
|
||||
raise Http404
|
||||
|
||||
if not is_logged_in_counter(request):
|
||||
raise PermissionDenied
|
||||
|
||||
self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"])
|
||||
|
||||
if not (
|
||||
request.barmen
|
||||
and request.barmen.issubset(self.counter.barmen_list)
|
||||
and self.counter.can_refill
|
||||
if (
|
||||
not is_logged_in_counter(request, counter=self.counter)
|
||||
or not self.counter.can_refill
|
||||
):
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
@@ -21,28 +21,30 @@ from django.views.generic.edit import CreateView, UpdateView
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from counter.forms import EticketForm
|
||||
from counter.models import Eticket, Selling
|
||||
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
||||
from counter.views.mixins import CounterAdminTabsMixin
|
||||
|
||||
|
||||
class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
class EticketListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||
"""A list view for the admins."""
|
||||
|
||||
model = Eticket
|
||||
template_name = "counter/eticket_list.jinja"
|
||||
ordering = ["id"]
|
||||
current_tab = "etickets"
|
||||
permission_required = "counter.view_eticket"
|
||||
|
||||
|
||||
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
class EticketCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||
"""Create an eticket."""
|
||||
|
||||
model = Eticket
|
||||
template_name = "core/create.jinja"
|
||||
form_class = EticketForm
|
||||
current_tab = "etickets"
|
||||
permission_required = "counter.add_eticket"
|
||||
|
||||
|
||||
class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""Edit an eticket."""
|
||||
|
||||
model = Eticket
|
||||
@@ -50,6 +52,7 @@ class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
form_class = EticketForm
|
||||
pk_url_kwarg = "eticket_id"
|
||||
current_tab = "etickets"
|
||||
permission_required = "counter.change_eticket"
|
||||
|
||||
|
||||
class EticketPDFView(CanViewMixin, DetailView):
|
||||
|
||||
@@ -16,9 +16,9 @@ from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import F
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@@ -36,6 +36,7 @@ from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterTabsMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.utils.safestring import SafeString
|
||||
|
||||
|
||||
@@ -155,7 +156,7 @@ class CounterMain(
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
class CounterLastOperationsView(CounterTabsMixin, UserPassesTestMixin, DetailView):
|
||||
"""Provide the last operations to allow barmen to delete them."""
|
||||
|
||||
model = Counter
|
||||
@@ -163,14 +164,12 @@ class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
template_name = "counter/last_ops.jinja"
|
||||
current_tab = "last_ops"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""We have here again a very particular right handling."""
|
||||
def test_func(self):
|
||||
self.object = self.get_object()
|
||||
if is_logged_in_counter(request) and self.object.barmen_list:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return HttpResponseRedirect(
|
||||
reverse("counter:details", kwargs={"counter_id": self.object.id})
|
||||
+ "?bad_location"
|
||||
return is_logged_in_counter(
|
||||
self.request, counter=self.object
|
||||
) or self.request.user.has_perms(
|
||||
["counter.view_selling", "counter.view_refilling"]
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
+73
-69
@@ -13,41 +13,16 @@
|
||||
#
|
||||
#
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic.base import View
|
||||
|
||||
from core.views.mixins import TabedViewMixin
|
||||
from counter.utils import is_logged_in_counter
|
||||
|
||||
|
||||
class CounterAdminMixin(View):
|
||||
"""Protect counter admin section."""
|
||||
|
||||
edit_group = [settings.SITH_GROUP_COUNTER_ADMIN_ID]
|
||||
edit_club = []
|
||||
|
||||
def _test_group(self, user):
|
||||
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
|
||||
|
||||
def _test_club(self, user):
|
||||
return any(c.can_be_edited_by(user) for c in self.edit_club)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not (
|
||||
request.user.is_root
|
||||
or self._test_group(request.user)
|
||||
or self._test_club(request.user)
|
||||
):
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class CounterTabsMixin(TabedViewMixin):
|
||||
def get_tabs_title(self):
|
||||
return self.object
|
||||
return self.object.name
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
if self.object.type != "BAR":
|
||||
@@ -71,7 +46,11 @@ class CounterTabsMixin(TabedViewMixin):
|
||||
"name": _("Cash summary"),
|
||||
}
|
||||
)
|
||||
if is_logged_in_counter(self.request):
|
||||
if is_logged_in_counter(
|
||||
self.request, counter=self.object
|
||||
) or self.request.user.has_perms(
|
||||
["counter.view_selling", "counter.view_refilling"]
|
||||
):
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
@@ -89,45 +68,70 @@ class CounterTabsMixin(TabedViewMixin):
|
||||
|
||||
class CounterAdminTabsMixin(TabedViewMixin):
|
||||
tabs_title = _("Counter administration")
|
||||
list_of_tabs = [
|
||||
{
|
||||
"url": reverse_lazy("counter:admin_list"),
|
||||
"slug": "counters",
|
||||
"name": _("Counters"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:product_list"),
|
||||
"slug": "products",
|
||||
"name": _("Products"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:product_formula_list"),
|
||||
"slug": "formulas",
|
||||
"name": _("Formulas"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:product_type_list"),
|
||||
"slug": "product_types",
|
||||
"name": _("Product types"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:returnable_list"),
|
||||
"slug": "returnable_products",
|
||||
"name": _("Returnable products"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:cash_summary_list"),
|
||||
"slug": "cash_summary",
|
||||
"name": _("Cash register summaries"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:invoices_call"),
|
||||
"slug": "invoices_call",
|
||||
"name": _("Invoices call"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:eticket_list"),
|
||||
"slug": "etickets",
|
||||
"name": _("Etickets"),
|
||||
},
|
||||
]
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
user = self.request.user
|
||||
res = [
|
||||
{
|
||||
"url": reverse_lazy("counter:admin_list"),
|
||||
"slug": "counters",
|
||||
"name": _("Counters"),
|
||||
}
|
||||
]
|
||||
if user.has_perm("counter.view_product"):
|
||||
res.append(
|
||||
{
|
||||
"url": reverse_lazy("counter:product_list"),
|
||||
"slug": "products",
|
||||
"name": _("Products"),
|
||||
}
|
||||
)
|
||||
if user.has_perm("counter.view_productformula"):
|
||||
res.append(
|
||||
{
|
||||
"url": reverse_lazy("counter:product_formula_list"),
|
||||
"slug": "formulas",
|
||||
"name": _("Formulas"),
|
||||
}
|
||||
)
|
||||
if user.has_perm("counter.view_producttype"):
|
||||
res.append(
|
||||
{
|
||||
"url": reverse_lazy("counter:product_type_list"),
|
||||
"slug": "product_types",
|
||||
"name": _("Product types"),
|
||||
}
|
||||
)
|
||||
if user.has_perm("counter.view_returnableproduct"):
|
||||
res.append(
|
||||
{
|
||||
"url": reverse_lazy("counter:returnable_list"),
|
||||
"slug": "returnable_products",
|
||||
"name": _("Returnable products"),
|
||||
}
|
||||
)
|
||||
if user.has_perm("counter.view_cashregistersummary"):
|
||||
res.append(
|
||||
{
|
||||
"url": reverse_lazy("counter:cash_summary_list"),
|
||||
"slug": "cash_summary",
|
||||
"name": _("Cash register summaries"),
|
||||
}
|
||||
)
|
||||
if user.has_perm("counter.view_invoicecall"):
|
||||
res.append(
|
||||
{
|
||||
"url": reverse_lazy("counter:invoices_call"),
|
||||
"slug": "invoices_call",
|
||||
"name": _("Invoices call"),
|
||||
}
|
||||
)
|
||||
if user.has_perm("counter.view_eticket"):
|
||||
res.append(
|
||||
{
|
||||
"url": reverse_lazy("counter:eticket_list"),
|
||||
"slug": "etickets",
|
||||
"name": _("Etickets"),
|
||||
}
|
||||
)
|
||||
return res
|
||||
@@ -48,7 +48,7 @@ des scripts complets mais qui restent lisibles,
|
||||
```
|
||||
Ok, c'est de la triche, on installe un package externe.
|
||||
Mais bon, ça marche, et l'équivalent pur bash
|
||||
serait carrément plus moche.
|
||||
serait un pur enfer.
|
||||
|
||||
=== "nu"
|
||||
|
||||
@@ -59,20 +59,18 @@ des scripts complets mais qui restent lisibles,
|
||||
|
||||
Nombre de lignes total :
|
||||
```nu
|
||||
ls **/*.py | insert linecount { get name | open | lines | length } | math sum
|
||||
ls **/*.py | par-each { open $in.name | lines | length } | math sum
|
||||
```
|
||||
|
||||
Vous pouvez aussi exlure les lignes vides
|
||||
et les les lignes de commentaire :
|
||||
```nu
|
||||
ls **/*.py |
|
||||
insert linecount {
|
||||
get name |
|
||||
open |
|
||||
par-each {
|
||||
open $in.name |
|
||||
lines |
|
||||
each { str trim } |
|
||||
filter { |l| not ($l | str starts-with "#") } | # commentaires
|
||||
filter { |l| ($l | str length) > 0 } | # lignes vides
|
||||
where { |l| ($l | str length) > 0 and not ($l | str starts-with "#") } |
|
||||
length
|
||||
} |
|
||||
math sum
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
::: api.schemas
|
||||
@@ -0,0 +1 @@
|
||||
::: api.views
|
||||
@@ -0,0 +1,378 @@
|
||||
Le site AE offre des mécanismes permettant aux applications tierces
|
||||
de récupérer les informations sur un utilisateur du site AE.
|
||||
De cette manière, il devient possible de synchroniser les informations
|
||||
qu possède l'application tierce sur l'utilisateur, directement depuis
|
||||
le site AE.
|
||||
|
||||
## Fonctionnement général
|
||||
|
||||
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
|
||||
et d'un client d'API (celui auquel est liée votre
|
||||
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
|
||||
Deux informations vous sont nécessaires, en plus de votre clef d'API :
|
||||
|
||||
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
|
||||
soit en appelant la route `GET /api/client/me` avec votre clef d'API
|
||||
renseignée dans le header [X-APIKey](./connect.md#x-apikey)
|
||||
- la clef HMAC du client : vous devez la demander à l'équipe info.
|
||||
|
||||
Ces deux éléments ont une fonction différente : l'id du client permet
|
||||
de dire au serveur quelle est l'application qui s'adresse à lui,
|
||||
tandis que la [clef HMAC](https://fr.wikipedia.org/wiki/HMAC)
|
||||
servira à créer une signature unique permettant de s'assurer
|
||||
que les données transmises n'ont pas été falsifiées.
|
||||
|
||||
Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
|
||||
au site AE pour qu'il authentifie vos utilisateurs.
|
||||
|
||||
En effet, la démarche d'authentification s'effectue presque entièrement
|
||||
sur le site : le travail de l'application tierce consiste uniquement
|
||||
à fournir à l'utilisateur une url avec les bons paramètres, puis
|
||||
à recevoir la réponse du serveur si tout s'est bien passé.
|
||||
|
||||
Comme un dessin vaut parfois mieux que mille mots,
|
||||
voici les diagrammes décrivant le processus.
|
||||
L'un montre l'entièreté de la démarche ;
|
||||
l'autre dans un souci de simplicité, ne montre que ce qui est visible
|
||||
directement par l'application tierce.
|
||||
|
||||
=== "Intégralité du processus"
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant App
|
||||
User->>+App: Authentifie-moi, stp
|
||||
App-->>-User: url de connexion<br/>avec signature
|
||||
User->>+Sith: GET url
|
||||
opt Utilisateur non-connecté
|
||||
Sith->>+User: Formulaire de connexion
|
||||
User-->>-Sith: Connexion
|
||||
end
|
||||
Sith->>Sith: vérification de la signature
|
||||
Sith->>+User: Formulaire<br/>des conditions<br/>d'utilisation
|
||||
User-->>-Sith: Validation
|
||||
Sith->>+App: URL de retour<br/>avec données utilisateur
|
||||
App->>App: Traitement des <br/>données utilisateur
|
||||
App-->>-Sith: 204 OK, No content
|
||||
Sith-->>-User: Message de succès
|
||||
App--)User: Message de succès
|
||||
```
|
||||
|
||||
=== "Point de vue de l'application tierce"
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant App
|
||||
User->>+App: Authentifie-moi, stp
|
||||
App-->>-User: url de connexion<br/>avec signature
|
||||
opt
|
||||
Sith->>+App: URL de retour<br/>avec données utilisateur
|
||||
App->>App: Traitement des <br/>données utilisateur
|
||||
App-->>-Sith: 204 OK, No content
|
||||
App--)User: Message de succès
|
||||
end
|
||||
```
|
||||
|
||||
## Données attendues
|
||||
|
||||
### URL de connexion
|
||||
|
||||
L'URL de connexion que vous allez fournir à l'utilisateur doit
|
||||
être `https://ae.utbm.fr/api-link/auth/`
|
||||
et doit contenir les données décrites dans
|
||||
[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
|
||||
|
||||
- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
|
||||
de la manière décrite plus haut
|
||||
- `third_party_app`(string) : le nom de la plateforme pour laquelle
|
||||
l'authentification va être réalisée (si votre application est un bot
|
||||
discord, mettez la valeur "discord")
|
||||
- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
|
||||
qui s'appliquera dans le cadre de l'application
|
||||
(s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
|
||||
- `username`(string) : le pseudonyme que l'utilisateur possède sur
|
||||
votre application
|
||||
- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
|
||||
réussit
|
||||
- `signature`(string) : la signature des données de la requête.
|
||||
Il s'agit d'une signature par clef HMAC dont le fonctionnement
|
||||
est détaillé plus bas.
|
||||
|
||||
Ces données doivent être url-encodées et passées dans les paramètres GET.
|
||||
|
||||
!!!warning "URL de retour"
|
||||
|
||||
Les URLs fournies doivent être des URLs HTTP valides.
|
||||
En outre, elles doivent obligatoirement inclure la barre oblique finale.
|
||||
|
||||
=== "URL correcte ✔️"
|
||||
|
||||
`https://exemple.ae.utbm.fr/foo/`
|
||||
|
||||
=== "URL incorrecte ❌"
|
||||
|
||||
`https://exemple.ae.utbm.fr/foo`
|
||||
|
||||
!!!tip
|
||||
|
||||
Inclure l'id de votre utilisateur dans l'URL de retour
|
||||
peut être un bon moyen de l'identifier lors du callback.
|
||||
Par exemple : `GET /callback/{int:user_id}/`.
|
||||
|
||||
???Example
|
||||
|
||||
Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
|
||||
avec les données suivantes :
|
||||
|
||||
- l'id du client est 15
|
||||
- sa clef HMAC est "beb99dd53"
|
||||
(c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
|
||||
- le pseudonyme discord de votre utilisateur est Brian
|
||||
- son id sur discord est 123456789
|
||||
- votre route de callback est `GET /callback/{int:user_id}/`,
|
||||
accessible au domaine `https://bot.ae.utbm.fr`
|
||||
|
||||
Alors les paramètres de votre URL seront :
|
||||
|
||||
| Paramètre | valeur |
|
||||
|-----------------|-----------------------------------------------------------------------|
|
||||
| client_id | 15 |
|
||||
| third_party_app | discord |
|
||||
| privacy_link | `https://discord.com/privacy` |
|
||||
| username | Brian |
|
||||
| callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
|
||||
| signature | 1a383c51060be64f07772aa42e07<br/>18ae096b8f21f2cdb4061c0834a416d12101 |
|
||||
|
||||
Et l'url fournie à l'utilisateur sera :
|
||||
|
||||
`https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
|
||||
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
|
||||
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
|
||||
&signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
|
||||
|
||||
### Données de retour
|
||||
|
||||
Si l'authentification réussit, le site AE enverra une requête HTTP POST
|
||||
à l'URL de retour fournie dans l'URL de connexion.
|
||||
|
||||
Le corps de la requête de callback et au format JSON
|
||||
et contient deux paires clef-valeur :
|
||||
|
||||
- `user` : les données utilisateur, telles que décrites
|
||||
par [UserProfileSchema][core.schemas.UserProfileSchema]
|
||||
- `signature` : la signature des données utilisateur
|
||||
|
||||
???Example
|
||||
|
||||
En reprenant les mêmes paramètres que dans l'exemple précédent,
|
||||
le site AE pourra renvoyer à l'application la requête suivante :
|
||||
|
||||
```http
|
||||
POST https://bot.ae.utbm.fr/callback/123456789/
|
||||
content-type: application/json
|
||||
body: {
|
||||
"user": {
|
||||
"id": 144131,
|
||||
"nick_name": "inzekitchen",
|
||||
"first_name": "Brian",
|
||||
...
|
||||
},
|
||||
"signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
|
||||
}
|
||||
```
|
||||
|
||||
L'application doit répondre avec un des codes HTTP suivants :
|
||||
|
||||
| Code | Raison |
|
||||
|------|--------------------------------------------------------------------------------|
|
||||
| 204 | Tout s'est bien passé |
|
||||
| 403 | Les données de retour ne sont <br>pas signées ou sont mal signées |
|
||||
| 404 | L'URL de retour ne permet pas <br>d'identifier un utilisateur de l'application |
|
||||
|
||||
!!!note "Code d'erreur par défaut"
|
||||
|
||||
Si l'appel de la route fait face à plusieurs problèmes en même temps
|
||||
(par exemple, l'URL ne permet pas de retrouver votre utilisateur,
|
||||
et en plus les données sont mal signées),
|
||||
le 403 prime et doit être retourné par défaut.
|
||||
|
||||
## Signature des données
|
||||
|
||||
Les données de l'URL de connexion doivent être signées,
|
||||
et la signature de l'URL de retour doit être vérifiée.
|
||||
|
||||
Dans le deux cas, la signature est le digest HMAC-SHA512
|
||||
des données url-encodées, en utilisant la clef HMAC du client d'API.
|
||||
L'ordre dans lequel ces données sont placées dans l'encodage URL
|
||||
doit être strictement le même que celui donné plus haut.
|
||||
|
||||
???Example "Signature de l'URL de connexion"
|
||||
|
||||
En reprenant le même exemple que les fois précédentes,
|
||||
l'url-encodage des données est :
|
||||
|
||||
`client_id=15&third_party_app=discord
|
||||
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
|
||||
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
|
||||
|
||||
Notez que la signature n'est pas (encore) dedans.
|
||||
Cette dernière peut-être obtenue avec le code suivant :
|
||||
|
||||
=== ":simple-python: Python"
|
||||
|
||||
Dépendances :
|
||||
|
||||
- `environs` (>=14.1)
|
||||
|
||||
```python
|
||||
import hmac
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from environs import Env
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
key = env.str("HMAC_KEY").encode()
|
||||
data = {
|
||||
"client_id": 15,
|
||||
"third_party_app": "discord",
|
||||
"privacy_link": "https://discord.com/privacy/",
|
||||
"username": "Brian",
|
||||
"callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
|
||||
}
|
||||
urlencoded = urlencode(data)
|
||||
data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
|
||||
|
||||
# URL a fournir à l'utilisateur pour son authentification
|
||||
user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
|
||||
```
|
||||
|
||||
=== ":simple-rust: Rust"
|
||||
|
||||
Dépendances :
|
||||
|
||||
- `hmac` (>=0.12.1)
|
||||
- `url` (>=2.5.7, features `serde`)
|
||||
- `serde` (>=1.0.228, features `derive`)
|
||||
- `serde_urlencoded` (>=0.7.1)
|
||||
- `sha2` (>=0.10.9)
|
||||
- `dotenvy` (>= 0.15)
|
||||
|
||||
```rust
|
||||
use hmac::{Mac, SimpleHmac};
|
||||
use serde::Serialize;
|
||||
use sha2::Sha512;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct UrlData<'a> {
|
||||
client_id: u32,
|
||||
third_party_app: &'a str,
|
||||
privacy_link: Url,
|
||||
username: &'a str,
|
||||
callback_url: Url,
|
||||
}
|
||||
|
||||
impl<'a> UrlData<'a> {
|
||||
pub fn signature(&self, key: &[u8]) -> CtOutput<SimpleHmac<Sha512>> {
|
||||
let urlencoded = serde_urlencoded::to_string(self).unwrap();
|
||||
SimpleHmac::<Sha512>::new_from_slice(key)
|
||||
.unwrap()
|
||||
.chain_update(urlencoded.as_bytes())
|
||||
.finalize()
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<Url> for UrlData<'_> {
|
||||
fn into(self) -> Url {
|
||||
let key = std::env::var("HMAC_KEY").unwrap();
|
||||
let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap();
|
||||
url.set_query(Some(
|
||||
format!(
|
||||
"{}&signature={:x}",
|
||||
serde_urlencoded::to_string(&self).unwrap(),
|
||||
self.signature(key.as_bytes()).into_bytes()
|
||||
)
|
||||
.as_str(),
|
||||
));
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
dotenvy::dotenv().expect("Couldn't load env");
|
||||
let data = UrlData {
|
||||
client_id: 1,
|
||||
third_party_app: "discord",
|
||||
privacy_link: "https://discord.com/privacy/".parse().unwrap(),
|
||||
username: "Brian",
|
||||
callback_url: "https://bot.ae.utbm.fr/callback/123456789/"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
};
|
||||
let url: Url = data.into();
|
||||
println!("{:?}", url);
|
||||
}
|
||||
```
|
||||
|
||||
???Example "Vérification de la signature de la réponse"
|
||||
|
||||
Les données utilisateur peuvent ressembler à :
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"display_name": "Matthieu Vincent",
|
||||
"profile_url": "/user/380/",
|
||||
"profile_pict": "/static/core/img/unknown.jpg",
|
||||
"id": 380,
|
||||
"nick_name": None,
|
||||
"first_name": "Matthieu",
|
||||
"last_name": "Vincent",
|
||||
},
|
||||
"signature": "3802a280fbb01bd9fetc."
|
||||
}
|
||||
```
|
||||
|
||||
Vous pouvez vérifier la signature ainsi :
|
||||
|
||||
```python
|
||||
import hmac
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from environs import Env
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
def is_signature_valid(user_data: dict, signature: str) -> bool:
|
||||
key = env.str("HMAC_KEY").encode()
|
||||
urlencoded = urlencode(user_data)
|
||||
return hmac.compare_digest(
|
||||
hmac.digest(key, urlencoded.encode(), "sha512").hex(),
|
||||
signature,
|
||||
)
|
||||
|
||||
|
||||
post_data = <récupération des données POST>
|
||||
print(
|
||||
"signature valide :",
|
||||
is_signature_valid(post_data["user"], post_data["signature"])
|
||||
)
|
||||
```
|
||||
|
||||
!!!Warning
|
||||
|
||||
Vous devez impérativement vérifier la signature
|
||||
des données de la requête de callback !
|
||||
|
||||
Ne pas vérifier la signature permet à n'importe quel acteur
|
||||
tierce malveillant de vous appeler sur votre callback.
|
||||
Ce serait une faille de sécurité majeure de votre côté.
|
||||
|
||||
Si l'équipe informatique se rend compte que vous ne le faites pas,
|
||||
elle se réserve le droit de suspendre votre application,
|
||||
immédiatement et sans préavis.
|
||||
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
|
||||
|
||||
Voici quelques exemples :
|
||||
|
||||
=== "Python (requests)"
|
||||
=== ":simple-python: Python (requests)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -132,7 +132,7 @@ Voici quelques exemples :
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
=== "Python (aiohttp)"
|
||||
=== ":simple-python: Python (aiohttp)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -159,7 +159,7 @@ Voici quelques exemples :
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
=== "Javascript (axios)"
|
||||
=== ":simple-javascript: Javascript (axios)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -179,7 +179,7 @@ Voici quelques exemples :
|
||||
console.log(await instance.get("club/1").json());
|
||||
```
|
||||
|
||||
=== "Rust (reqwest)"
|
||||
=== ":simple-rust: Rust (reqwest)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
|
||||
+40
-16
@@ -18,7 +18,8 @@ il n'est pas possible de développer le site sur ce système d'exploitation.
|
||||
Heureusement, il existe une alternative qui ne requiert pas de désinstaller votre
|
||||
OS ni de mettre un dual boot sur votre ordinateur : `WSL`.
|
||||
|
||||
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou Windows 11.
|
||||
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou
|
||||
Windows 11.
|
||||
- **Plus d'info:** [docs.microsoft.com](https://docs.microsoft.com/fr-fr/windows/wsl/install)
|
||||
|
||||
```shell
|
||||
@@ -43,7 +44,7 @@ il suffit d'utiliser la commande suivante :
|
||||
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
```
|
||||
|
||||
!!!note
|
||||
!!!success
|
||||
|
||||
À ce stade, si vous avez réussi votre installation de `WSL` ou bien qu'il
|
||||
était déjà installé, vous pouvez effectuer la mise en place du projet en suivant
|
||||
@@ -101,10 +102,10 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
ln -s /usr/bin/valkey-server /usr/bin/redis-server
|
||||
```
|
||||
|
||||
|
||||
=== "macOS"
|
||||
|
||||
Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
|
||||
Pour installer les dépendances, il est fortement recommandé
|
||||
d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
|
||||
Il est également nécessaire d'avoir installé xcode
|
||||
|
||||
```bash
|
||||
@@ -116,12 +117,15 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
|
||||
!!!note
|
||||
|
||||
Si vous rencontrez des erreurs lors de votre configuration, n'hésitez pas à vérifier l'état de votre installation homebrew avec :code:`brew doctor`
|
||||
Si vous rencontrez des erreurs lors de votre configuration,
|
||||
n'hésitez pas à vérifier l'état de votre installation homebrew
|
||||
avec :code:`brew doctor`
|
||||
|
||||
!!!note
|
||||
|
||||
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
||||
installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets.
|
||||
installé par uv. Il est cependant parfois nécessaire d'installer
|
||||
les headers Python nécessaire à la compilation de certains paquets.
|
||||
|
||||
## Finaliser l'installation
|
||||
|
||||
@@ -145,7 +149,6 @@ uv run ./manage.py install_xapian
|
||||
de texte à l'écran.
|
||||
C'est normal, il ne faut pas avoir peur.
|
||||
|
||||
|
||||
La modification de la configuration passe par la modification de variables
|
||||
d'environnement.
|
||||
Ces variables sont stockées dans un fichier `.env`.
|
||||
@@ -174,8 +177,8 @@ uv run ./manage.py compilemessages
|
||||
|
||||
## Démarrer le serveur de développement
|
||||
|
||||
Il faut toujours avoir préalablement activé
|
||||
l'environnement virtuel comme fait plus haut
|
||||
Il faut toujours avoir préalablement activé
|
||||
l'environnement virtuel comme fait plus haut
|
||||
et se placer à la racine du projet.
|
||||
Il suffit ensuite d'utiliser cette commande :
|
||||
|
||||
@@ -183,11 +186,32 @@ Il suffit ensuite d'utiliser cette commande :
|
||||
uv run ./manage.py runserver
|
||||
```
|
||||
|
||||
!!!note
|
||||
Le serveur est alors accessible à l'adresse
|
||||
[http://localhost:8000](http://localhost:8000)
|
||||
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
|
||||
|
||||
Le serveur est alors accessible à l'adresse
|
||||
[http://localhost:8000](http://localhost:8000)
|
||||
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
|
||||
Pour vous connecter, vous pouvez utiliser un des comptes utilisateur
|
||||
créés durant l'installation :
|
||||
|
||||
| username | description |
|
||||
|------------------|----------------------------------------------------------------------------|
|
||||
| `root` | super-utilisateur<br/>il peut tout faire et tout voir |
|
||||
| `subscriber` | simple cotisant |
|
||||
| `old_subscriber` | ancien cotisant |
|
||||
| `public` | non-cotisant |
|
||||
| `krophil` | cotisant<br/>barman Foyer<br/>président du Troll Penché |
|
||||
| `sli` | cotisant<br/>VP Troll |
|
||||
| `skia` | cotisant<br/>barman MDE<br/>membre du bureau AE<br/>ancien membre du Troll |
|
||||
| `comunity` | cotisant<br/>membre du bureau PdF<br/>admin communication |
|
||||
| `counter` | cotisant<br/>admin comptoirs |
|
||||
| `comptable` | cotisant<br/>admin comptabilité |
|
||||
| `rbatsbak` | cotisant<br/>membre du Troll |
|
||||
|
||||
En plus de ces dernier, quelques centaines d'utilisateurs
|
||||
supplémentaires sont générés procéduralement,
|
||||
afin de rajouter un peu de volume de données.
|
||||
|
||||
Tous les utilisateurs ont le mot de passe `plop`.
|
||||
|
||||
!!!note
|
||||
|
||||
@@ -228,10 +252,10 @@ uv run ./manage.py runserver
|
||||
|
||||
La documentation est automatiquement mise en ligne à chaque envoi de code sur GitHub.
|
||||
Pour l'utiliser en local ou globalement pour la modifier,
|
||||
il existe une commande du site qui génère
|
||||
il existe une commande du site qui génère
|
||||
la documentation et lance un serveur la rendant
|
||||
accessible à l'adresse [http://localhost:8080](http://localhost:8000).
|
||||
Cette commande génère la documentation à
|
||||
Cette commande génère la documentation à
|
||||
chacune de ses modifications,
|
||||
inutile de relancer le serveur à chaque fois.
|
||||
|
||||
@@ -241,7 +265,7 @@ uv run mkdocs serve
|
||||
|
||||
## Lancer les tests
|
||||
|
||||
Pour lancer les tests, il suffit d'utiliser
|
||||
Pour lancer les tests, il suffit d'utiliser
|
||||
la commande suivante :
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#
|
||||
# Copyright 2022
|
||||
# - Maréchal <thgirod@hotmail.com
|
||||
#
|
||||
# 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.
|
||||
|
||||
|
||||
class PaymentResultConverter:
|
||||
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
|
||||
|
||||
It's meant to build an url that can match
|
||||
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
|
||||
but nothing else.
|
||||
"""
|
||||
|
||||
regex = "(success|failure)"
|
||||
|
||||
def to_python(self, value):
|
||||
return str(value)
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value)
|
||||
@@ -67,7 +67,7 @@
|
||||
.fa-plus,
|
||||
.fa-minus {
|
||||
cursor: pointer;
|
||||
background-color: #354a5f;
|
||||
background-color: var(--deepblue);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
padding: 5px;
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@
|
||||
|
||||
from django.urls import path, register_converter
|
||||
|
||||
from eboutic.converters import PaymentResultConverter
|
||||
from core.converters import ResultConverter
|
||||
from eboutic.views import (
|
||||
BillingInfoFormFragment,
|
||||
EbouticCheckout,
|
||||
@@ -34,7 +34,7 @@ from eboutic.views import (
|
||||
payment_result,
|
||||
)
|
||||
|
||||
register_converter(PaymentResultConverter, "res")
|
||||
register_converter(ResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
# Subscription views
|
||||
|
||||
@@ -288,7 +288,7 @@ $min_col_width: 100px;
|
||||
border: none;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-white);
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.18em;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
}
|
||||
|
||||
.topic {
|
||||
border: solid $primary-neutral-color 1px;
|
||||
border: solid var(--darkened-deepblue) 1px;
|
||||
border-radius: 5px;
|
||||
padding: 1px;
|
||||
margin: 1px;
|
||||
|
||||
@@ -23,7 +24,7 @@
|
||||
}
|
||||
|
||||
a {
|
||||
color: $black-color;
|
||||
color: var(--text-black);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@@ -56,7 +57,7 @@
|
||||
}
|
||||
|
||||
.forum {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-white);
|
||||
padding: 1px;
|
||||
margin: 1px;
|
||||
|
||||
@@ -66,7 +67,7 @@
|
||||
}
|
||||
|
||||
a {
|
||||
color: $black-color;
|
||||
color: var(--text-black);
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
@@ -91,7 +92,7 @@
|
||||
|
||||
.category {
|
||||
margin-top: 5px;
|
||||
background: $secondary-color;
|
||||
background: var(--deepblue);
|
||||
color: white;
|
||||
border-radius: 10px 10px 0 0;
|
||||
.title {
|
||||
@@ -105,12 +106,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: $secondary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
border-radius: 5px;
|
||||
border: 1px darken($secondary-neutral-light-color, 10%) solid;
|
||||
border: 1px var(--bg-lightgrey) solid;
|
||||
|
||||
&:nth-child(odd) {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-white);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
|
||||
+5
-2
@@ -346,13 +346,16 @@ class ForumTopicDetailView(CanViewMixin, DetailView):
|
||||
return kwargs
|
||||
|
||||
|
||||
class ForumMessageView(SingleObjectMixin, RedirectView):
|
||||
class ForumMessageView(SingleObjectMixin, UserPassesTestMixin, RedirectView):
|
||||
model = ForumMessage
|
||||
pk_url_kwarg = "message_id"
|
||||
permanent = False
|
||||
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
def test_func(self) -> bool:
|
||||
self.object = self.get_object()
|
||||
return self.request.user.can_view(self.object)
|
||||
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
return self.object.get_url()
|
||||
|
||||
|
||||
|
||||
+421
-309
File diff suppressed because it is too large.
Load diff
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user