Merge pull request #849 from ae-utbm/taiste

New 3DSv2 fields and Bugfixes
This commit is contained in:
thomas girod 2024-09-30 11:33:32 +02:00 committed by GitHub
commit 3548deebf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 884 additions and 370 deletions

View File

@ -2,6 +2,7 @@ from typing import Annotated
import annotated_types
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
@ -49,10 +50,16 @@ class UserController(ControllerBase):
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks)
@route.get("/search", response=PaginatedResponseSchema[UserProfileSchema])
@route.get(
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
)
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter(User.objects.all())
return filters.filter(
User.objects.order_by(F("last_login").desc(nulls_last=True))
)
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]

View File

@ -1,9 +1,11 @@
from datetime import timedelta
from django.conf import settings
from django.utils.timezone import now
from model_bakery import seq
from model_bakery.recipe import Recipe, related
from club.models import Membership
from core.models import User
from subscription.models import Subscription
@ -24,9 +26,27 @@ subscriber_user = Recipe(
last_name=seq("user "),
subscriptions=related(active_subscription),
)
"""A user with an active subscription."""
old_subscriber_user = Recipe(
User,
first_name="old subscriber",
last_name=seq("user "),
subscriptions=related(ended_subscription),
)
"""A user with an ended subscription."""
ae_board_membership = Recipe(
Membership,
start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
)
board_user = Recipe(
User,
first_name="AE",
last_name=seq("member "),
memberships=related(ae_board_membership),
)
"""A user which is in the board of the AE."""

View File

@ -1057,20 +1057,38 @@ class SithFile(models.Model):
if self.is_file and (self.file is None or self.file == ""):
raise ValidationError(_("You must provide a file"))
def apply_rights_recursively(self, *, only_folders=False):
children = self.children.all()
if only_folders:
children = children.filter(is_folder=True)
for c in children:
c.copy_rights()
c.apply_rights_recursively(only_folders=only_folders)
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:
"""Apply the rights of this file to all children recursively.
Args:
only_folders: If True, only apply the rights to SithFiles that are folders.
"""
file_ids = []
explored_ids = [self.id]
while len(explored_ids) > 0: # find all children recursively
file_ids.extend(explored_ids)
next_level = SithFile.objects.filter(parent_id__in=explored_ids)
if only_folders:
next_level = next_level.filter(is_folder=True)
explored_ids = list(next_level.values_list("id", flat=True))
for through in (SithFile.view_groups.through, SithFile.edit_groups.through):
# force evaluation. Without this, the iterator yields nothing
groups = list(
through.objects.filter(sithfile_id=self.id).values_list(
"group_id", flat=True
)
)
# delete previous rights
through.objects.filter(sithfile_id__in=file_ids).delete()
through.objects.bulk_create( # create new rights
[through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]
)
def copy_rights(self):
"""Copy, if possible, the rights of the parent folder."""
if self.parent is not None:
self.edit_groups.set(self.parent.edit_groups.all())
self.view_groups.set(self.parent.view_groups.all())
self.save()
def move_to(self, parent):
"""Move a file to a new parent.

View File

@ -66,7 +66,6 @@ class UserFilterSchema(FilterSchema):
SearchQuerySet()
.models(User)
.autocomplete(auto=slugify(value).replace("-", " "))
.order_by("-last_update")
.values_list("pk", flat=True)
)
)

View File

@ -33,6 +33,9 @@ class UserIndex(indexes.SearchIndex, indexes.Indexable):
text = indexes.CharField(document=True, use_template=True)
auto = indexes.EdgeNgramField(use_template=True)
last_update = indexes.DateTimeField(model_attr="last_update")
last_login = indexes.DateTimeField(
model_attr="last_login", default="1970-01-01T00:00:00Z"
)
def get_model(self):
return User

View File

@ -108,7 +108,8 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
return url;
}
// TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost.
/**
* Given a paginated endpoint, fetch all the items of this endpoint,
* performing multiple API calls if necessary.
@ -135,7 +136,7 @@ async function fetch_paginated(url) {
fetch(paginated_url).then(res => res.json().then(json => json.results))
);
}
results.push(...await Promise.all(promises))
results.push(...(await Promise.all(promises)).flat())
}
return results;
}

View File

@ -306,6 +306,12 @@ a:not(.button) {
align-items: center;
text-align: justify;
&.alert-yellow {
background-color: rgb(255, 255, 240);
color: rgb(99, 87, 6);
border: rgb(192, 180, 16) 1px solid;
}
&.alert-green {
background-color: rgb(245, 255, 245);
color: rgb(3, 84, 63);

View File

@ -77,11 +77,11 @@ function create_graph(container, data, active_user_id) {
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: 'DOWN',
nodePlacement: 'INTERACTIVE',
layoutHierarchy: true
}
}
direction: "DOWN",
nodePlacement: "INTERACTIVE",
layoutHierarchy: true,
},
},
});
let active_user = cy
.getElementById(active_user_id)
@ -178,7 +178,9 @@ document.addEventListener("alpine:init", () => {
typeof depth_min === "undefined" ||
typeof depth_max === "undefined"
) {
console.error("Some constants are not set before using the family_graph script, please look at the documentation");
console.error(
"Some constants are not set before using the family_graph script, please look at the documentation",
);
return;
}
@ -194,7 +196,7 @@ document.addEventListener("alpine:init", () => {
loading: false,
godfathers_depth: get_initial_depth("godfathers_depth"),
godchildren_depth: get_initial_depth("godchildren_depth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === 'true',
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined,
graph_data: {},
@ -227,7 +229,11 @@ document.addEventListener("alpine:init", () => {
async screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = gettext("family_tree.%(extension)s", "jpg");
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

View File

@ -0,0 +1,110 @@
function alpine_webcam_builder(
default_picture,
delete_url,
can_delete_picture,
) {
return () => ({
can_edit_picture: false,
loading: false,
is_camera_enabled: false,
is_camera_error: false,
picture: null,
video: null,
picture_form: null,
init() {
this.video = this.$refs.video;
this.picture_form = this.$refs.form.getElementsByTagName("input");
if (this.picture_form.length > 0) {
this.picture_form = this.picture_form[0];
this.can_edit_picture = true;
// Link the displayed element to the form input
this.picture_form.onchange = (event) => {
let files = event.srcElement.files;
if (files.length > 0) {
this.picture = (window.URL || window.webkitURL).createObjectURL(
event.srcElement.files[0],
);
} else {
this.picture = null;
}
};
}
},
get_picture() {
return this.picture || default_picture;
},
delete_picture() {
// Only remove currently displayed picture
if (!!this.picture) {
let list = new DataTransfer();
this.picture_form.files = list.files;
this.picture_form.dispatchEvent(new Event("change"));
return;
}
if (!can_delete_picture) {
return;
}
// Remove user picture if correct rights are available
window.open(delete_url, "_self");
},
enable_camera() {
this.picture = null;
this.loading = true;
this.is_camera_error = false;
navigator.mediaDevices
.getUserMedia({ video: true, audio: false })
.then((stream) => {
this.loading = false;
this.is_camera_enabled = true;
this.video.srcObject = stream;
this.video.play();
})
.catch((err) => {
this.is_camera_error = true;
this.loading = false;
});
},
take_picture() {
let canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
/* Create the image */
let settings = this.video.srcObject.getTracks()[0].getSettings();
canvas.width = settings.width;
canvas.height = settings.height;
context.drawImage(this.video, 0, 0, canvas.width, canvas.height);
/* Stop camera */
this.video.pause();
this.video.srcObject.getTracks().forEach((track) => {
if (track.readyState === "live") {
track.stop();
}
});
canvas.toBlob((blob) => {
const filename = interpolate(gettext("captured.%s"), ["webp"]);
let file = new File([blob], filename, {
type: "image/webp",
});
let list = new DataTransfer();
list.items.add(file);
this.picture_form.files = list.files;
// No change event is triggered, we trigger it manually #}
this.picture_form.dispatchEvent(new Event("change"));
}, "image/webp");
canvas.remove();
this.is_camera_enabled = false;
},
});
}

View File

@ -8,7 +8,12 @@
<link rel="stylesheet" href="{{ scss('user/user_edit.scss') }}">
{%- endblock -%}
{% block additional_js %}
<script defer src="{{ static("user/js/user_edit.js") }}"></script>
{% endblock %}
{% macro profile_picture(field_name) %}
{% set this_picture = form.instance[field_name] %}
<div class="profile-picture" x-data="camera_{{ field_name }}" >
<div class="profile-picture-display" :aria-busy="loading" :class="{ 'camera-error': is_camera_error }">
<img
@ -50,8 +55,8 @@
<div>
{{ form[field_name] }}
<button class="btn btn-red" @click.prevent="delete_picture()"
{%- if not (user.is_root and form.instance[field_name]) -%}
:disabled="picture == null"
{%- if not (this_picture and this_picture.is_owned_by(user)) -%}
:disabled="!picture"
{%- endif -%}
x-cloak
>
@ -68,128 +73,25 @@
</div>
</div>
<script>
{%- if this_picture -%}
{% set default_picture = this_picture.get_download_url()|tojson %}
{% set delete_url = (
url('core:file_delete', file_id=this_picture.id, popup='')
+"?next=" + profile.get_absolute_url()
)|tojson %}
{%- else -%}
{% set default_picture = static('core/img/unknown.jpg')|tojson %}
{% set delete_url = "null" %}
{%- endif -%}
document.addEventListener("alpine:init", () => {
Alpine.data("camera_{{ field_name }}", () => ({
can_edit_picture: false,
loading: false,
is_camera_enabled: false,
is_camera_error: false,
picture: null,
video: null,
picture_form: null,
init() {
this.video = this.$refs.video;
this.picture_form = this.$refs.form.getElementsByTagName("input");
if (this.picture_form.length > 0){
this.picture_form = this.picture_form[0];
this.can_edit_picture = true;
{# Link the displayed element to the form input #}
this.picture_form.onchange = (event) => {
let files = event.srcElement.files;
if (files.length > 0){
this.picture = (window.URL || window.webkitURL)
.createObjectURL(event.srcElement.files[0]);
} else {
this.picture = null;
}
}
}
},
get_picture() {
if (this.picture != null) {
return this.picture;
}
{%- if form.instance[field_name] -%}
return "{{ form.instance[field_name].get_download_url() }}"
{%- else -%}
return "{{ static('core/img/unknown.jpg') }}"
{%- endif -%}
},
delete_picture() {
{# Only remove currently displayed picture #}
if (this.picture != null){
let list = new DataTransfer();
this.picture_form.files = list.files;
this.picture_form.dispatchEvent(new Event("change"));
return;
}
{# Remove user picture if correct rights are available #}
{%- if user.is_root and form.instance[field_name] -%}
window.open(
'{{ url(
'core:file_delete',
file_id=form.instance[field_name].id,
popup=''
) }}',
'_self');
{%- endif -%}
},
enable_camera() {
this.picture = null;
this.loading = true;
this.is_camera_error = false;
navigator.mediaDevices
.getUserMedia({ video: true, audio: false })
.then((stream) => {
this.loading = false;
this.is_camera_enabled = true;
this.video.srcObject = stream;
this.video.play();
})
.catch((err) => {
this.is_camera_error = true;
this.loading = false;
});
},
take_picture() {
let canvas = document.createElement("canvas")
const context = canvas.getContext("2d");
/* Create the image */
let settings = this.video.srcObject.getTracks()[0].getSettings();
canvas.width = settings.width;
canvas.height = settings.height;
context.drawImage(this.video, 0, 0, canvas.width, canvas.height);
/* Stop camera */
this.video.pause()
this.video.srcObject.getTracks().forEach((track) => {
if (track.readyState === 'live') {
track.stop();
}
});
canvas.toBlob((blob) => {
let file = new File(
[blob],
"{% trans %}captured{% endtrans %}.webp",
{ type: "image/webp" },
);
let list = new DataTransfer();
list.items.add(file);
this.picture_form.files = list.files;
{# No change event is triggered, we trigger it manually #}
this.picture_form.dispatchEvent(new Event("change"));
}, "image/webp");
canvas.remove();
this.is_camera_enabled = false;
},
}));
Alpine.data(
"camera_{{ field_name }}",
alpine_webcam_builder(
{{ default_picture }},
{{ delete_url }},
{{ (this_picture and this_picture.is_owned_by(user))|tojson }}
)
);
});
</script>
{% endmacro %}

View File

@ -356,82 +356,6 @@ class TestUserTools:
assert response.status_code == 200
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:
# - renaming a page
# - changing a page's parent --> check that page's children's full_name
# - changing the different groups of the page
class TestFileHandling(TestCase):
@classmethod
def setUpTestData(cls):
cls.subscriber = User.objects.get(username="subscriber")
def setUp(self):
self.client.login(username="subscriber", password="plop")
def test_create_folder_home(self):
response = self.client.post(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id}),
{"folder_name": "GUY_folder_test"},
)
assert response.status_code == 302
response = self.client.get(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id})
)
assert response.status_code == 200
assert "GUY_folder_test</a>" in str(response.content)
def test_upload_file_home(self):
with open("/bin/ls", "rb") as f:
response = self.client.post(
reverse(
"core:file_detail", kwargs={"file_id": self.subscriber.home.id}
),
{"file_field": f},
)
assert response.status_code == 302
response = self.client.get(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id})
)
assert response.status_code == 200
assert "ls</a>" in str(response.content)
class TestUserIsInGroup(TestCase):
"""Test that the User.is_in_group() and AnonymousUser.is_in_group()
work as intended.

View File

@ -22,7 +22,7 @@ class TestFetchFamilyApi(TestCase):
# <- user5
cls.main_user = baker.make(User)
cls.users = baker.make(User, _quantity=17)
cls.users = baker.make(User, _quantity=17, _bulk_create=True)
cls.main_user.godfathers.add(*cls.users[0:3])
cls.main_user.godchildren.add(*cls.users[3:6])
cls.users[1].godfathers.add(cls.users[6])

221
core/tests/test_files.py Normal file
View File

@ -0,0 +1,221 @@
from io import BytesIO
from itertools import cycle
from typing import Callable
from uuid import uuid4
import pytest
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe, foreign_key
from PIL import Image
from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, SithFile, User
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:
# - renaming a page
# - changing a page's parent --> check that page's children's full_name
# - changing the different groups of the page
class TestFileHandling(TestCase):
@classmethod
def setUpTestData(cls):
cls.subscriber = User.objects.get(username="subscriber")
def setUp(self):
self.client.login(username="subscriber", password="plop")
def test_create_folder_home(self):
response = self.client.post(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id}),
{"folder_name": "GUY_folder_test"},
)
assert response.status_code == 302
response = self.client.get(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id})
)
assert response.status_code == 200
assert "GUY_folder_test</a>" in str(response.content)
def test_upload_file_home(self):
with open("/bin/ls", "rb") as f:
response = self.client.post(
reverse(
"core:file_detail", kwargs={"file_id": self.subscriber.home.id}
),
{"file_field": f},
)
assert response.status_code == 302
response = self.client.get(
reverse("core:file_detail", kwargs={"file_id": self.subscriber.home.id})
)
assert response.status_code == 200
assert "ls</a>" in str(response.content)
@pytest.mark.django_db
class TestUserProfilePicture:
"""Test interactions with user's profile picture."""
@pytest.fixture
def user(self) -> User:
pict = foreign_key(Recipe(SithFile), one_to_one=True)
return subscriber_user.extend(profile_pict=pict).make()
@staticmethod
def delete_picture_request(user: User, client: Client):
return client.post(
reverse(
"core:file_delete",
kwargs={"file_id": user.profile_pict.pk, "popup": ""},
)
+ f"?next={user.get_absolute_url()}"
)
@pytest.mark.parametrize(
"user_factory",
[lambda: baker.make(User, is_superuser=True), board_user.make],
)
def test_delete_picture_successful(
self, user: User, user_factory: Callable[[], User], client: Client
):
"""Test that root and board members can delete a user's profile picture."""
cache.clear()
operator = user_factory()
client.force_login(operator)
res = self.delete_picture_request(user, client)
assert res.status_code == 302
assert res.url == user.get_absolute_url()
user.refresh_from_db()
assert user.profile_pict is None
@pytest.mark.parametrize(
"user_factory",
[lambda: baker.make(User), subscriber_user.make],
)
def test_delete_picture_unauthorized(
self, user: User, user_factory, client: Client
):
"""Test that regular users can't delete a user's profile picture."""
cache.clear()
operator = user_factory()
client.force_login(operator)
original_picture = user.profile_pict
res = self.delete_picture_request(user, client)
assert res.status_code == 403
user.refresh_from_db()
assert user.profile_pict is not None
assert user.profile_pict == original_picture
def test_user_cannot_delete_own_picture(self, user: User, client: Client):
"""Test that a user can't delete their own profile picture."""
cache.clear()
client.force_login(user)
original_picture = user.profile_pict
res = self.delete_picture_request(user, client)
assert res.status_code == 403
user.refresh_from_db()
assert user.profile_pict is not None
assert user.profile_pict == original_picture
def test_user_set_own_picture(self, user: User, client: Client):
"""Test that a user can set their own profile picture if they have none."""
user.profile_pict.delete()
user.profile_pict = None
user.save()
cache.clear()
client.force_login(user)
img = Image.new("RGB", (10, 10))
content = BytesIO()
img.save(content, format="JPEG")
name = str(uuid4())
res = client.post(
reverse("core:user_edit", kwargs={"user_id": user.pk}),
data={
# birthdate, email and tshirt_size are required by the form
"date_of_birth": "1990-01-01",
"email": f"{uuid4()}@gmail.com",
"tshirt_size": "M",
"profile_pict": SimpleUploadedFile(
f"{name}.jpg", content.getvalue(), content_type="image/jpeg"
),
},
)
assert res.status_code == 302
user.refresh_from_db()
assert user.profile_pict is not None
# uploaded images should be converted to WEBP
assert Image.open(user.profile_pict.file).format == "WEBP"
@pytest.mark.django_db
def test_apply_rights_recursively():
"""Test that the apply_rights_recursively method works as intended."""
files = [baker.make(SithFile)]
files.extend(baker.make(SithFile, _quantity=3, parent=files[0], _bulk_create=True))
files.extend(
baker.make(SithFile, _quantity=3, parent=iter(files[1:4]), _bulk_create=True)
)
files.extend(
baker.make(SithFile, _quantity=6, parent=cycle(files[4:7]), _bulk_create=True)
)
groups = list(baker.make(Group, _quantity=7))
files[0].view_groups.set(groups[:3])
files[0].edit_groups.set(groups[2:6])
# those groups should be erased after the function call
files[1].view_groups.set(groups[6:])
with assertNumQueries(10):
# 1 query for each level of depth (here 4)
# 1 query to get the view_groups of the first file
# 1 query to delete the previous view_groups
# 1 query apply the new view_groups
# same 3 queries for the edit_groups
files[0].apply_rights_recursively()
for file in SithFile.objects.filter(pk__in=[f.pk for f in files]).prefetch_related(
"view_groups", "edit_groups"
):
assert set(file.view_groups.all()) == set(groups[:3])
assert set(file.edit_groups.all()) == set(groups[2:6])

97
core/tests/test_user.py Normal file
View File

@ -0,0 +1,97 @@
from datetime import timedelta
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user
from core.models import User
class TestSearchUsers(TestCase):
@classmethod
def setUpTestData(cls):
User.objects.all().delete()
user_recipe = Recipe(
User,
first_name=seq("First", suffix="Name"),
last_name=seq("Last", suffix="Name"),
nick_name=seq("Nick", suffix="Name"),
)
cls.users = [
user_recipe.make(last_login=None),
*user_recipe.make(
last_login=seq(now() - timedelta(days=30), timedelta(days=1)),
_quantity=10,
_bulk_create=True,
),
]
call_command("update_index", "core", "--remove")
@classmethod
def tearDownClass(cls):
super().tearDownClass()
# restore the index
call_command("update_index", "core", "--remove")
class TestSearchUsersAPI(TestSearchUsers):
def test_order(self):
"""Test that users are ordered by last login date."""
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("api:search_users") + "?search=First")
assert response.status_code == 200
assert response.json()["count"] == 11
# The users are ordered by last login date, so we need to reverse the list
assert [r["id"] for r in response.json()["results"]] == [
u.id for u in self.users[::-1]
]
def test_search_case_insensitive(self):
"""Test that the search is case insensitive."""
self.client.force_login(subscriber_user.make())
expected = [u.id for u in self.users[::-1]]
for term in ["first", "First", "FIRST"]:
response = self.client.get(reverse("api:search_users") + f"?search={term}")
assert response.status_code == 200
assert response.json()["count"] == 11
assert [r["id"] for r in response.json()["results"]] == expected
def test_search_nick_name(self):
"""Test that the search can be done on the nick name."""
self.client.force_login(subscriber_user.make())
# this should return users with nicknames Nick11, Nick10 and Nick1
response = self.client.get(reverse("api:search_users") + "?search=Nick1")
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id,
self.users[0].id,
]
def test_search_special_characters(self):
"""Test that the search can be done on special characters."""
belix = baker.make(User, nick_name="Bélix")
call_command("update_index", "core")
self.client.force_login(subscriber_user.make())
# this should return users with first names First1 and First10
response = self.client.get(reverse("api:search_users") + "?search=bél")
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [belix.id]
class TestSearchUsersView(TestSearchUsers):
"""Test the search user view (`GET /search`)."""
def test_page_ok(self):
"""Just test that the page loads."""
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("core:search"))
assert response.status_code == 200

View File

@ -357,7 +357,7 @@ class FileDeleteView(CanEditPropMixin, DeleteView):
def get_success_url(self):
self.object.file.delete() # Doing it here or overloading delete() is the same, so let's do it here
if "next" in self.request.GET.keys():
if "next" in self.request.GET:
return self.request.GET["next"]
if self.object.parent is None:
return reverse(

View File

@ -85,7 +85,8 @@ def search_user(query):
SearchQuerySet()
.models(User)
.autocomplete(auto=query)
.order_by("-last_update")[:20]
.order_by("-last_login")
.load_all()[:20]
)
return [r.object for r in res]
except TypeError:

View File

@ -46,6 +46,7 @@ class CustomerAdmin(SearchModelAdmin):
@admin.register(BillingInfo)
class BillingInfoAdmin(admin.ModelAdmin):
list_display = ("first_name", "last_name", "address_1", "city", "country")
autocomplete_fields = ("customer",)
@admin.register(Counter)

View File

@ -2,6 +2,7 @@ from ajax_select import make_ajax_field
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from django import forms
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from counter.models import (
@ -26,7 +27,11 @@ class BillingInfoForm(forms.ModelForm):
"zip_code",
"city",
"country",
"phone_number",
]
widgets = {
"phone_number": RegionalPhoneNumberWidget,
}
class StudentCardForm(forms.ModelForm):

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.16 on 2024-09-26 10:28
import phonenumber_field.modelfields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("counter", "0022_alter_product_icon"),
]
operations = [
migrations.AddField(
model_name="billinginfo",
name="phone_number",
field=phonenumber_field.modelfields.PhoneNumberField(
max_length=128, null=True, region=None, verbose_name="Phone number"
),
),
]

View File

@ -34,6 +34,7 @@ from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from phonenumber_field.modelfields import PhoneNumberField
from accounting.models import CurrencyField
from club.models import Club
@ -176,6 +177,14 @@ class BillingInfo(models.Model):
city = models.CharField(_("City"), max_length=50)
country = CountryField(blank_label=_("Country"))
# This table was created during the A22 semester.
# However, later on, CA asked for the phone number to be added to the billing info.
# As the table was already created, this new field had to be nullable,
# even tough it is required by the bank and shouldn't be null.
# If one day there is no null phone number remaining,
# please make the field non-nullable.
phone_number = PhoneNumberField(_("Phone number"), null=True, blank=False)
def __str__(self):
return f"{self.first_name} {self.last_name}"
@ -192,6 +201,8 @@ class BillingInfo(models.Model):
"ZipCode": self.zip_code,
"City": self.city,
"CountryCode": self.country.numeric, # ISO-3166-1 numeric code
"MobilePhone": self.phone_number.as_national.replace(" ", ""),
"CountryCodeMobilePhone": f"+{self.phone_number.country_code}",
}
}
if self.address_2:

View File

@ -315,6 +315,7 @@ class TestBillingInfo:
"zip_code": "34301",
"city": "Sète",
"country": "FR",
"phone_number": "0612345678",
}
def test_edit_infos(self, client: Client, payload: dict):
@ -356,7 +357,7 @@ class TestBillingInfo:
for key, val in payload.items():
assert getattr(infos, key) == val
def test_invalid_data(self, client: Client, payload):
def test_invalid_data(self, client: Client, payload: dict[str, str]):
user = subscriber_user.make()
client.force_login(user)
# address_1, zip_code and country are missing
@ -391,6 +392,60 @@ class TestBillingInfo:
)
assert response.status_code == expected_code
@pytest.mark.parametrize(
"phone_number",
["+33612345678", "0612345678", "06 12 34 56 78", "06-12-34-56-78"],
)
def test_phone_number_format(
self, client: Client, payload: dict, phone_number: str
):
"""Test that various formats of phone numbers are accepted."""
user = subscriber_user.make()
client.force_login(user)
payload["phone_number"] = phone_number
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
infos = BillingInfo.objects.get(customer__user=user)
assert infos.phone_number == "0612345678"
assert infos.phone_number.country_code == 33
def test_foreign_phone_number(self, client: Client, payload: dict):
"""Test that a foreign phone number is accepted."""
user = subscriber_user.make()
client.force_login(user)
payload["phone_number"] = "+49612345678"
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
infos = BillingInfo.objects.get(customer__user=user)
assert infos.phone_number.as_national == "06123 45678"
assert infos.phone_number.country_code == 49
@pytest.mark.parametrize(
"phone_number", ["061234567a", "06 12 34 56", "061234567879", "azertyuiop"]
)
def test_invalid_phone_number(
self, client: Client, payload: dict, phone_number: str
):
"""Test that invalid phone numbers are rejected."""
user = subscriber_user.make()
client.force_login(user)
payload["phone_number"] = phone_number
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 422
assert not BillingInfo.objects.filter(customer__user=user).exists()
class TestBarmanConnection(TestCase):
@classmethod

View File

@ -1,6 +1,11 @@
from typing import Annotated
from ninja import ModelSchema, Schema
from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter
# from phonenumber_field.phonenumber import PhoneNumber
from pydantic_extra_types.phone_numbers import PhoneNumber, PhoneNumberValidator
from counter.models import BillingInfo
@ -31,3 +36,8 @@ class BillingInfoSchema(ModelSchema):
"country",
]
fields_optional = ["customer"]
# for reasons described in the model, BillingInfo.phone_number
# in nullable, but null values shouldn't be actually allowed,
# so we force the field to be required
phone_number: Annotated[PhoneNumber, PhoneNumberValidator(default_region="FR")]

View File

@ -42,7 +42,16 @@ document.addEventListener("alpine:init", () => {
this.req_state = res.ok
? BillingInfoReqState.SUCCESS
: BillingInfoReqState.FAILURE;
if (res.ok) {
if (res.status === 422) {
const errors = (await res.json())["detail"].map((err) => err["loc"]).flat();
Array.from(form.querySelectorAll("input"))
.filter((elem) => errors.includes(elem.name))
.forEach((elem) => {
elem.setCustomValidity(gettext("Incorrect value"));
elem.reportValidity();
elem.oninput = () => elem.setCustomValidity("");
});
} else if (res.ok) {
Alpine.store("billing_inputs").fill();
}
},

View File

@ -98,13 +98,20 @@
</form>
</div>
<br>
{% if must_fill_billing_infos %}
<p>
<i>
{% trans %}You must fill your billing infos if you want to pay with your credit
card{% endtrans %}
</i>
</p>
{% if billing_infos_state == BillingInfoState.EMPTY %}
<div class="alert alert-yellow">
{% trans %}You must fill your billing infos if you want to pay with your credit
card{% endtrans %}
</div>
{% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %}
<div class="alert alert-yellow">
{% trans %}
The Crédit Agricole changed its policy related to the billing
information that must be provided in order to pay with a credit card.
If you want to pay with your credit card, you must add a phone number
to the data you already provided.
{% endtrans %}
</div>
{% endif %}
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
<template x-data x-for="[key, value] in Object.entries($store.billing_inputs.data)">
@ -113,7 +120,7 @@
<input
type="submit"
id="bank-submit-button"
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
{% if billing_infos_state != BillingInfoState.VALID %}disabled="disabled"{% endif %}
value="{% trans %}Pay with credit card{% endtrans %}"
/>
</form>

View File

@ -16,6 +16,7 @@
import base64
import json
from datetime import datetime
from enum import Enum
import sentry_sdk
from cryptography.exceptions import InvalidSignature
@ -82,6 +83,12 @@ def payment_result(request, result: str) -> HttpResponse:
return render(request, "eboutic/eboutic_payment_result.jinja", context)
class BillingInfoState(Enum):
VALID = 1
EMPTY = 2
MISSING_PHONE_NUMBER = 3
class EbouticCommand(LoginRequiredMixin, TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja"
basket: Basket
@ -130,9 +137,16 @@ class EbouticCommand(LoginRequiredMixin, TemplateView):
default_billing_info = customer.billing_infos
else:
kwargs["customer_amount"] = None
kwargs["must_fill_billing_infos"] = default_billing_info is None
if not kwargs["must_fill_billing_infos"]:
# the user has already filled its billing_infos, thus we can
# make the enum available in the template
kwargs["BillingInfoState"] = BillingInfoState
if default_billing_info is None:
kwargs["billing_infos_state"] = BillingInfoState.EMPTY
elif default_billing_info.phone_number is None:
kwargs["billing_infos_state"] = BillingInfoState.MISSING_PHONE_NUMBER
else:
kwargs["billing_infos_state"] = BillingInfoState.VALID
if kwargs["billing_infos_state"] == BillingInfoState.VALID:
# the user has already filled all of its billing_infos, thus we can
# get it without expecting an error
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
kwargs["basket"] = self.basket

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-17 12:21+0200\n"
"POT-Creation-Date: 2024-09-26 17:51+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Skia <skia@libskia.so>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -18,8 +18,8 @@ msgstr ""
#: accounting/models.py:62 accounting/models.py:103 accounting/models.py:136
#: accounting/models.py:203 club/models.py:54 com/models.py:274
#: com/models.py:293 counter/models.py:209 counter/models.py:242
#: counter/models.py:377 forum/models.py:59 launderette/models.py:29
#: com/models.py:293 counter/models.py:213 counter/models.py:246
#: counter/models.py:381 forum/models.py:59 launderette/models.py:29
#: launderette/models.py:84 launderette/models.py:122 stock/models.py:36
#: stock/models.py:57 stock/models.py:97 stock/models.py:125
msgid "name"
@ -66,8 +66,8 @@ msgid "account number"
msgstr "numéro de compte"
#: accounting/models.py:109 accounting/models.py:140 club/models.py:344
#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:265
#: counter/models.py:379 trombi/models.py:210
#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:269
#: counter/models.py:383 trombi/models.py:210
msgid "club"
msgstr "club"
@ -88,12 +88,12 @@ msgstr "Compte club"
msgid "%(club_account)s on %(bank_account)s"
msgstr "%(club_account)s sur %(bank_account)s"
#: accounting/models.py:201 club/models.py:350 counter/models.py:860
#: accounting/models.py:201 club/models.py:350 counter/models.py:864
#: election/models.py:16 launderette/models.py:179
msgid "start date"
msgstr "date de début"
#: accounting/models.py:202 club/models.py:351 counter/models.py:861
#: accounting/models.py:202 club/models.py:351 counter/models.py:865
#: election/models.py:17
msgid "end date"
msgstr "date de fin"
@ -106,8 +106,8 @@ msgstr "est fermé"
msgid "club account"
msgstr "compte club"
#: accounting/models.py:212 accounting/models.py:272 counter/models.py:56
#: counter/models.py:583
#: accounting/models.py:212 accounting/models.py:272 counter/models.py:57
#: counter/models.py:587
msgid "amount"
msgstr "montant"
@ -127,20 +127,20 @@ msgstr "numéro"
msgid "journal"
msgstr "classeur"
#: accounting/models.py:273 core/models.py:940 core/models.py:1442
#: core/models.py:1487 core/models.py:1516 core/models.py:1540
#: counter/models.py:593 counter/models.py:686 counter/models.py:896
#: accounting/models.py:273 core/models.py:940 core/models.py:1460
#: core/models.py:1505 core/models.py:1534 core/models.py:1558
#: counter/models.py:597 counter/models.py:690 counter/models.py:900
#: eboutic/models.py:57 eboutic/models.py:173 forum/models.py:311
#: forum/models.py:412 stock/models.py:96
msgid "date"
msgstr "date"
#: accounting/models.py:274 counter/models.py:211 counter/models.py:897
#: accounting/models.py:274 counter/models.py:215 counter/models.py:901
#: pedagogy/models.py:207 stock/models.py:99
msgid "comment"
msgstr "commentaire"
#: accounting/models.py:276 counter/models.py:595 counter/models.py:688
#: accounting/models.py:276 counter/models.py:599 counter/models.py:692
#: subscription/models.py:56
msgid "payment method"
msgstr "méthode de paiement"
@ -166,8 +166,8 @@ msgid "accounting type"
msgstr "type comptable"
#: accounting/models.py:311 accounting/models.py:450 accounting/models.py:483
#: accounting/models.py:515 core/models.py:1515 core/models.py:1541
#: counter/models.py:652
#: accounting/models.py:515 core/models.py:1533 core/models.py:1559
#: counter/models.py:656
msgid "label"
msgstr "étiquette"
@ -266,7 +266,7 @@ msgstr ""
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
"standard"
#: accounting/models.py:442 counter/models.py:252 pedagogy/models.py:41
#: accounting/models.py:442 counter/models.py:256 pedagogy/models.py:41
msgid "code"
msgstr "code"
@ -370,7 +370,7 @@ msgstr "Compte en banque : "
#: core/templates/core/user_account_detail.jinja:66
#: core/templates/core/user_clubs.jinja:34
#: core/templates/core/user_clubs.jinja:63
#: core/templates/core/user_edit.jinja:57
#: core/templates/core/user_edit.jinja:62
#: core/templates/core/user_preferences.jinja:48
#: counter/templates/counter/last_ops.jinja:35
#: counter/templates/counter/last_ops.jinja:65
@ -968,11 +968,11 @@ msgstr "Une action est requise"
msgid "You must specify at least an user or an email address"
msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
#: club/forms.py:153 counter/forms.py:186
#: club/forms.py:153 counter/forms.py:187
msgid "Begin date"
msgstr "Date de début"
#: club/forms.py:156 com/views.py:82 com/views.py:201 counter/forms.py:189
#: club/forms.py:156 com/views.py:82 com/views.py:201 counter/forms.py:190
#: election/views.py:167 subscription/views.py:38
msgid "End date"
msgstr "Date de fin"
@ -1058,7 +1058,7 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
msgid "A club with that unix_name already exists"
msgstr "Un club avec ce nom UNIX existe déjà."
#: club/models.py:336 counter/models.py:851 counter/models.py:887
#: club/models.py:336 counter/models.py:855 counter/models.py:891
#: eboutic/models.py:53 eboutic/models.py:169 election/models.py:183
#: launderette/models.py:136 launderette/models.py:198 sas/models.py:270
#: trombi/models.py:206
@ -1070,8 +1070,8 @@ msgstr "nom d'utilisateur"
msgid "role"
msgstr "rôle"
#: club/models.py:358 core/models.py:89 counter/models.py:210
#: counter/models.py:243 election/models.py:13 election/models.py:115
#: club/models.py:358 core/models.py:89 counter/models.py:214
#: counter/models.py:247 election/models.py:13 election/models.py:115
#: election/models.py:188 forum/models.py:60 forum/models.py:244
msgid "description"
msgstr "description"
@ -1440,7 +1440,7 @@ msgstr "résumé"
msgid "content"
msgstr "contenu"
#: com/models.py:71 core/models.py:1485 launderette/models.py:92
#: com/models.py:71 core/models.py:1503 launderette/models.py:92
#: launderette/models.py:130 launderette/models.py:181 stock/models.py:74
#: stock/models.py:129
msgid "type"
@ -2217,7 +2217,7 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
#: core/templates/core/user_detail.jinja:110
#: core/templates/core/user_detail.jinja:112
#: core/templates/core/user_detail.jinja:113
#: core/templates/core/user_edit.jinja:16
#: core/templates/core/user_edit.jinja:21
#: election/templates/election/election_detail.jinja:132
#: election/templates/election/election_detail.jinja:134
#: forum/templates/forum/macros.jinja:104
@ -2250,7 +2250,7 @@ msgstr "avoir une notification pour chaque rechargement"
msgid "file name"
msgstr "nom du fichier"
#: core/models.py:899 core/models.py:1234
#: core/models.py:899 core/models.py:1252
msgid "parent"
msgstr "parent"
@ -2266,11 +2266,11 @@ msgstr "miniature"
msgid "owner"
msgstr "propriétaire"
#: core/models.py:932 core/models.py:1251 core/views/files.py:223
#: core/models.py:932 core/models.py:1269 core/views/files.py:223
msgid "edit group"
msgstr "groupe d'édition"
#: core/models.py:935 core/models.py:1254 core/views/files.py:226
#: core/models.py:935 core/models.py:1272 core/views/files.py:226
msgid "view group"
msgstr "groupe de vue"
@ -2316,11 +2316,11 @@ msgstr "Un fichier de ce nom existe déjà"
msgid "You must provide a file"
msgstr "Vous devez fournir un fichier"
#: core/models.py:1217
#: core/models.py:1235
msgid "page unix name"
msgstr "nom unix de la page"
#: core/models.py:1223
#: core/models.py:1241
msgid ""
"Enter a valid page name. This value may contain only unaccented letters, "
"numbers and ./+/-/_ characters."
@ -2328,55 +2328,55 @@ msgstr ""
"Entrez un nom de page correct. Uniquement des lettres non accentuées, "
"numéros, et ./+/-/_"
#: core/models.py:1241
#: core/models.py:1259
msgid "page name"
msgstr "nom de la page"
#: core/models.py:1246
#: core/models.py:1264
msgid "owner group"
msgstr "groupe propriétaire"
#: core/models.py:1259
#: core/models.py:1277
msgid "lock user"
msgstr "utilisateur bloquant"
#: core/models.py:1266
#: core/models.py:1284
msgid "lock_timeout"
msgstr "décompte du déblocage"
#: core/models.py:1316
#: core/models.py:1334
msgid "Duplicate page"
msgstr "Une page de ce nom existe déjà"
#: core/models.py:1319
#: core/models.py:1337
msgid "Loop in page tree"
msgstr "Boucle dans l'arborescence des pages"
#: core/models.py:1439
#: core/models.py:1457
msgid "revision"
msgstr "révision"
#: core/models.py:1440
#: core/models.py:1458
msgid "page title"
msgstr "titre de la page"
#: core/models.py:1441
#: core/models.py:1459
msgid "page content"
msgstr "contenu de la page"
#: core/models.py:1482
#: core/models.py:1500
msgid "url"
msgstr "url"
#: core/models.py:1483
#: core/models.py:1501
msgid "param"
msgstr "param"
#: core/models.py:1488
#: core/models.py:1506
msgid "viewed"
msgstr "vue"
#: core/models.py:1546
#: core/models.py:1564
msgid "operation type"
msgstr "type d'opération"
@ -2474,7 +2474,7 @@ msgstr "Forum"
msgid "Gallery"
msgstr "Photos"
#: core/templates/core/base.jinja:225 counter/models.py:387
#: core/templates/core/base.jinja:225 counter/models.py:391
#: counter/templates/counter/counter_list.jinja:11
#: eboutic/templates/eboutic/eboutic_main.jinja:4
#: eboutic/templates/eboutic/eboutic_main.jinja:22
@ -2693,7 +2693,7 @@ msgid "Edit group"
msgstr "Éditer le groupe"
#: core/templates/core/group_edit.jinja:9
#: core/templates/core/user_edit.jinja:268
#: core/templates/core/user_edit.jinja:170
#: core/templates/core/user_group.jinja:13
#: pedagogy/templates/pedagogy/uv_edit.jinja:36
msgid "Update"
@ -3184,39 +3184,35 @@ msgstr "Aucun cadeau donné pour l'instant"
msgid "Edit user"
msgstr "Éditer l'utilisateur"
#: core/templates/core/user_edit.jinja:36
#: core/templates/core/user_edit.jinja:41
msgid "Enable camera"
msgstr "Activer la caméra"
#: core/templates/core/user_edit.jinja:44
#: core/templates/core/user_edit.jinja:49
msgid "Take a picture"
msgstr "Prendre une photo"
#: core/templates/core/user_edit.jinja:64
#: core/templates/core/user_edit.jinja:69
msgid "To edit your profile picture, ask a member of the AE"
msgstr "Pour changer votre photo de profil, demandez à un membre de l'AE"
#: core/templates/core/user_edit.jinja:173
msgid "captured"
msgstr "capturé"
#: core/templates/core/user_edit.jinja:196
#: core/templates/core/user_edit.jinja:98
msgid "Edit user profile"
msgstr "Éditer le profil de l'utilisateur"
#: core/templates/core/user_edit.jinja:258
#: core/templates/core/user_edit.jinja:160
msgid "Change my password"
msgstr "Changer mon mot de passe"
#: core/templates/core/user_edit.jinja:263
#: core/templates/core/user_edit.jinja:165
msgid "Change user password"
msgstr "Changer le mot de passe"
#: core/templates/core/user_edit.jinja:273
#: core/templates/core/user_edit.jinja:175
msgid "Username:"
msgstr "Nom d'utilisateur : "
#: core/templates/core/user_edit.jinja:276
#: core/templates/core/user_edit.jinja:178
msgid "Account number:"
msgstr "Numéro de compte : "
@ -3350,7 +3346,7 @@ msgstr "Achats"
msgid "Product top 10"
msgstr "Top 10 produits"
#: core/templates/core/user_stats.jinja:43 counter/forms.py:200
#: core/templates/core/user_stats.jinja:43 counter/forms.py:201
msgid "Product"
msgstr "Produit"
@ -3395,7 +3391,7 @@ msgstr "Cotisations"
msgid "Subscription stats"
msgstr "Statistiques de cotisation"
#: core/templates/core/user_tools.jinja:48 counter/forms.py:159
#: core/templates/core/user_tools.jinja:48 counter/forms.py:160
#: counter/views.py:728
msgid "Counters"
msgstr "Comptoirs"
@ -3654,7 +3650,7 @@ msgstr "Parrain / Marraine"
msgid "Godchild"
msgstr "Fillot / Fillote"
#: core/views/forms.py:338 counter/forms.py:67 trombi/views.py:149
#: core/views/forms.py:338 counter/forms.py:68 trombi/views.py:149
msgid "Select user"
msgstr "Choisir un utilisateur"
@ -3713,24 +3709,24 @@ msgstr "Photos"
msgid "Galaxy"
msgstr "Galaxie"
#: counter/apps.py:30 counter/models.py:403 counter/models.py:857
#: counter/models.py:893 launderette/models.py:32 stock/models.py:39
#: counter/apps.py:30 counter/models.py:407 counter/models.py:861
#: counter/models.py:897 launderette/models.py:32 stock/models.py:39
msgid "counter"
msgstr "comptoir"
#: counter/forms.py:48
#: counter/forms.py:49
msgid "This UID is invalid"
msgstr "Cet UID est invalide"
#: counter/forms.py:89
#: counter/forms.py:90
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: counter/forms.py:145
#: counter/forms.py:146
msgid "Parent product"
msgstr "Produit parent"
#: counter/forms.py:151
#: counter/forms.py:152
msgid "Buying groups"
msgstr "Groupes d'achat"
@ -3738,165 +3734,169 @@ msgstr "Groupes d'achat"
msgid "Ecocup regularization"
msgstr "Régularization des ecocups"
#: counter/models.py:55
#: counter/models.py:56
msgid "account id"
msgstr "numéro de compte"
#: counter/models.py:57
#: counter/models.py:58
msgid "recorded product"
msgstr "produits consignés"
#: counter/models.py:60
#: counter/models.py:61
msgid "customer"
msgstr "client"
#: counter/models.py:61
#: counter/models.py:62
msgid "customers"
msgstr "clients"
#: counter/models.py:73 counter/views.py:309
#: counter/models.py:74 counter/views.py:309
msgid "Not enough money"
msgstr "Solde insuffisant"
#: counter/models.py:171
#: counter/models.py:172
msgid "First name"
msgstr "Prénom"
#: counter/models.py:172
#: counter/models.py:173
msgid "Last name"
msgstr "Nom de famille"
#: counter/models.py:173
#: counter/models.py:174
msgid "Address 1"
msgstr "Adresse 1"
#: counter/models.py:174
#: counter/models.py:175
msgid "Address 2"
msgstr "Adresse 2"
#: counter/models.py:175
#: counter/models.py:176
msgid "Zip code"
msgstr "Code postal"
#: counter/models.py:176
#: counter/models.py:177
msgid "City"
msgstr "Ville"
#: counter/models.py:177
#: counter/models.py:178
msgid "Country"
msgstr "Pays"
#: counter/models.py:221 counter/models.py:247
#: counter/models.py:179
msgid "Phone number"
msgstr "Numéro de téléphone"
#: counter/models.py:225 counter/models.py:251
msgid "product type"
msgstr "type du produit"
#: counter/models.py:253
#: counter/models.py:257
msgid "purchase price"
msgstr "prix d'achat"
#: counter/models.py:254
#: counter/models.py:258
msgid "selling price"
msgstr "prix de vente"
#: counter/models.py:255
#: counter/models.py:259
msgid "special selling price"
msgstr "prix de vente spécial"
#: counter/models.py:262
#: counter/models.py:266
msgid "icon"
msgstr "icône"
#: counter/models.py:267
#: counter/models.py:271
msgid "limit age"
msgstr "âge limite"
#: counter/models.py:268
#: counter/models.py:272
msgid "tray price"
msgstr "prix plateau"
#: counter/models.py:272
#: counter/models.py:276
msgid "parent product"
msgstr "produit parent"
#: counter/models.py:278
#: counter/models.py:282
msgid "buying groups"
msgstr "groupe d'achat"
#: counter/models.py:280 election/models.py:50
#: counter/models.py:284 election/models.py:50
msgid "archived"
msgstr "archivé"
#: counter/models.py:283 counter/models.py:993
#: counter/models.py:287 counter/models.py:997
msgid "product"
msgstr "produit"
#: counter/models.py:382
#: counter/models.py:386
msgid "products"
msgstr "produits"
#: counter/models.py:385
#: counter/models.py:389
msgid "counter type"
msgstr "type de comptoir"
#: counter/models.py:387
#: counter/models.py:391
msgid "Bar"
msgstr "Bar"
#: counter/models.py:387
#: counter/models.py:391
msgid "Office"
msgstr "Bureau"
#: counter/models.py:390
#: counter/models.py:394
msgid "sellers"
msgstr "vendeurs"
#: counter/models.py:398 launderette/models.py:192
#: counter/models.py:402 launderette/models.py:192
msgid "token"
msgstr "jeton"
#: counter/models.py:601
#: counter/models.py:605
msgid "bank"
msgstr "banque"
#: counter/models.py:603 counter/models.py:693
#: counter/models.py:607 counter/models.py:697
msgid "is validated"
msgstr "est validé"
#: counter/models.py:606
#: counter/models.py:610
msgid "refilling"
msgstr "rechargement"
#: counter/models.py:670 eboutic/models.py:227
#: counter/models.py:674 eboutic/models.py:227
msgid "unit price"
msgstr "prix unitaire"
#: counter/models.py:671 counter/models.py:973 eboutic/models.py:228
#: counter/models.py:675 counter/models.py:977 eboutic/models.py:228
msgid "quantity"
msgstr "quantité"
#: counter/models.py:690
#: counter/models.py:694
msgid "Sith account"
msgstr "Compte utilisateur"
#: counter/models.py:690 sith/settings.py:405 sith/settings.py:410
#: counter/models.py:694 sith/settings.py:405 sith/settings.py:410
#: sith/settings.py:430
msgid "Credit card"
msgstr "Carte bancaire"
#: counter/models.py:696
#: counter/models.py:700
msgid "selling"
msgstr "vente"
#: counter/models.py:800
#: counter/models.py:804
msgid "Unknown event"
msgstr "Événement inconnu"
#: counter/models.py:801
#: counter/models.py:805
#, python-format
msgid "Eticket bought for the event %(event)s"
msgstr "Eticket acheté pour l'événement %(event)s"
#: counter/models.py:803 counter/models.py:826
#: counter/models.py:807 counter/models.py:830
#, python-format
msgid ""
"You bought an eticket for the event %(event)s.\n"
@ -3908,63 +3908,63 @@ msgstr ""
"Vous pouvez également retrouver tous vos e-tickets sur votre page de compte "
"%(url)s."
#: counter/models.py:862
#: counter/models.py:866
msgid "last activity date"
msgstr "dernière activité"
#: counter/models.py:865
#: counter/models.py:869
msgid "permanency"
msgstr "permanence"
#: counter/models.py:898
#: counter/models.py:902
msgid "emptied"
msgstr "coffre vidée"
#: counter/models.py:901
#: counter/models.py:905
msgid "cash register summary"
msgstr "relevé de caisse"
#: counter/models.py:969
#: counter/models.py:973
msgid "cash summary"
msgstr "relevé"
#: counter/models.py:972
#: counter/models.py:976
msgid "value"
msgstr "valeur"
#: counter/models.py:975
#: counter/models.py:979
msgid "check"
msgstr "chèque"
#: counter/models.py:977
#: counter/models.py:981
msgid "True if this is a bank check, else False"
msgstr "Vrai si c'est un chèque, sinon Faux."
#: counter/models.py:981
#: counter/models.py:985
msgid "cash register summary item"
msgstr "élément de relevé de caisse"
#: counter/models.py:997
#: counter/models.py:1001
msgid "banner"
msgstr "bannière"
#: counter/models.py:999
#: counter/models.py:1003
msgid "event date"
msgstr "date de l'événement"
#: counter/models.py:1001
#: counter/models.py:1005
msgid "event title"
msgstr "titre de l'événement"
#: counter/models.py:1003
#: counter/models.py:1007
msgid "secret"
msgstr "secret"
#: counter/models.py:1042
#: counter/models.py:1046
msgid "uid"
msgstr "uid"
#: counter/models.py:1047
#: counter/models.py:1051
msgid "student cards"
msgstr "cartes étudiante"
@ -4439,40 +4439,58 @@ msgstr "Solde restant : "
msgid "Billing information"
msgstr "Informations de facturation"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:104
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:103
msgid ""
"You must fill your billing infos if you want to pay with your credit\n"
" card"
" card"
msgstr ""
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
"par carte bancaire"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:117
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:108
msgid ""
"\n"
" The Crédit Agricole changed its policy related to the "
"billing\n"
" information that must be provided in order to pay with a "
"credit card.\n"
" If you want to pay with your credit card, you must add a "
"phone number\n"
" to the data you already provided.\n"
" "
msgstr ""
"\n"
"Le Crédit Agricole a changé sa politique relative aux informations à "
"fournir pour effectuer un paiement par carte bancaire. De ce fait, si vous "
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:124
msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:122
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:129
msgid ""
"AE account payment disabled because your basket contains refilling items."
msgstr ""
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
"rechargement."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:124
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:131
msgid ""
"AE account payment disabled because you do not have enough money remaining."
msgstr ""
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:129
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:136
msgid "Pay with Sith account"
msgstr "Payer avec un compte AE"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:140
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:147
msgid "Billing info registration success"
msgstr "Informations de facturation enregistrées"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:141
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:148
msgid "Billing info registration failure"
msgstr "Echec de l'enregistrement des informations de facturation."
@ -6325,3 +6343,6 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format
msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s"
#~ msgid "captured"
#~ msgstr "capturé"

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-03 15:22+0200\n"
"POT-Creation-Date: 2024-09-27 22:32+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -16,9 +16,24 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: core/static/user/js/family_graph.js:230
#: core/static/user/js/family_graph.js:233
msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s"
#: sas/static/sas/js/picture.js:52
#: core/static/user/js/user_edit.js:93
#, javascript-format
msgid "captured.%s"
msgstr "capture.%s"
#: eboutic/static/eboutic/js/makecommand.js:50
msgid "Incorrect value"
msgstr "Valeur incorrecte"
#: sas/static/sas/js/viewer.js:196
msgid "Couldn't moderate picture"
msgstr "Echec de la suppression de la photo"
#: sas/static/sas/js/viewer.js:209
msgid "Couldn't delete picture"
msgstr "Echec de la suppression de la photo"

29
poetry.lock generated
View File

@ -1813,6 +1813,33 @@ files = [
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pydantic-extra-types"
version = "2.9.0"
description = "Extra Pydantic types."
optional = false
python-versions = ">=3.8"
files = []
develop = false
[package.dependencies]
pydantic = ">=2.5.2"
typing-extensions = "*"
[package.extras]
all = ["pendulum (>=3.0.0,<4.0.0)", "phonenumbers (>=8,<9)", "pycountry (>=23)", "python-ulid (>=1,<2)", "python-ulid (>=1,<3)", "pytz (>=2024.1)", "semver (>=3.0.2)", "semver (>=3.0.2,<3.1.0)", "tzdata (>=2024.1)"]
pendulum = ["pendulum (>=3.0.0,<4.0.0)"]
phonenumbers = ["phonenumbers (>=8,<9)"]
pycountry = ["pycountry (>=23)"]
python-ulid = ["python-ulid (>=1,<2)", "python-ulid (>=1,<3)"]
semver = ["semver (>=3.0.2)"]
[package.source]
type = "git"
url = "https://github.com/pydantic/pydantic-extra-types.git"
reference = "HEAD"
resolved_reference = "58db4b096d7c90566d3d48d51b4665c01a591df6"
[[package]]
name = "pygments"
version = "2.18.0"
@ -2626,4 +2653,4 @@ filelock = ">=3.4"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "b6202203d272cecdb607ea8ebc1ba12dd8369e4f387f65692c4a9681915e6f48"
content-hash = "c9c49497cc576b24c96ea914b74ef5c3a0c2981c488a599752f05aabb575f8d8"

View File

@ -45,6 +45,10 @@ dict2xml = "^1.7.3"
Sphinx = "^5" # Needed for building xapian
tomli = "^2.0.1"
django-honeypot = "^1.2.1"
# When I introduced pydantic-extra-types, I needed *right now*
# the PhoneNumberValidator class which was on the master branch but not released yet.
# Once it's released, switch this to a regular version.
pydantic-extra-types = { git = "https://github.com/pydantic/pydantic-extra-types.git", rev = "58db4b0" }
[tool.poetry.group.prod.dependencies]
# deps used in prod, but unnecessary for development

View File

@ -201,7 +201,7 @@ document.addEventListener("alpine:init", () => {
},
async delete_picture() {
const res = await fetch(`/api/sas/picture/${this.current_picture.id}/`, {
const res = await fetch(`/api/sas/picture/${this.current_picture.id}`, {
method: "DELETE",
});
if (!res.ok) {