mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-22 14:13:21 +00:00
Merge pull request #849 from ae-utbm/taiste
New 3DSv2 fields and Bugfixes
This commit is contained in:
commit
3548deebf6
11
core/api.py
11
core/api.py
@ -2,6 +2,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
import annotated_types
|
import annotated_types
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db.models import F
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from ninja import Query
|
from ninja import Query
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
@ -49,10 +50,16 @@ class UserController(ControllerBase):
|
|||||||
def fetch_profiles(self, pks: Query[set[int]]):
|
def fetch_profiles(self, pks: Query[set[int]]):
|
||||||
return User.objects.filter(pk__in=pks)
|
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)
|
@paginate(PageNumberPaginationExtra, page_size=20)
|
||||||
def search_users(self, filters: Query[UserFilterSchema]):
|
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)]
|
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe, related
|
from model_bakery.recipe import Recipe, related
|
||||||
|
|
||||||
|
from club.models import Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
@ -24,9 +26,27 @@ subscriber_user = Recipe(
|
|||||||
last_name=seq("user "),
|
last_name=seq("user "),
|
||||||
subscriptions=related(active_subscription),
|
subscriptions=related(active_subscription),
|
||||||
)
|
)
|
||||||
|
"""A user with an active subscription."""
|
||||||
|
|
||||||
old_subscriber_user = Recipe(
|
old_subscriber_user = Recipe(
|
||||||
User,
|
User,
|
||||||
first_name="old subscriber",
|
first_name="old subscriber",
|
||||||
last_name=seq("user "),
|
last_name=seq("user "),
|
||||||
subscriptions=related(ended_subscription),
|
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."""
|
||||||
|
@ -1057,20 +1057,38 @@ class SithFile(models.Model):
|
|||||||
if self.is_file and (self.file is None or self.file == ""):
|
if self.is_file and (self.file is None or self.file == ""):
|
||||||
raise ValidationError(_("You must provide a file"))
|
raise ValidationError(_("You must provide a file"))
|
||||||
|
|
||||||
def apply_rights_recursively(self, *, only_folders=False):
|
def apply_rights_recursively(self, *, only_folders: bool = False) -> None:
|
||||||
children = self.children.all()
|
"""Apply the rights of this file to all children recursively.
|
||||||
if only_folders:
|
|
||||||
children = children.filter(is_folder=True)
|
Args:
|
||||||
for c in children:
|
only_folders: If True, only apply the rights to SithFiles that are folders.
|
||||||
c.copy_rights()
|
"""
|
||||||
c.apply_rights_recursively(only_folders=only_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):
|
def copy_rights(self):
|
||||||
"""Copy, if possible, the rights of the parent folder."""
|
"""Copy, if possible, the rights of the parent folder."""
|
||||||
if self.parent is not None:
|
if self.parent is not None:
|
||||||
self.edit_groups.set(self.parent.edit_groups.all())
|
self.edit_groups.set(self.parent.edit_groups.all())
|
||||||
self.view_groups.set(self.parent.view_groups.all())
|
self.view_groups.set(self.parent.view_groups.all())
|
||||||
self.save()
|
|
||||||
|
|
||||||
def move_to(self, parent):
|
def move_to(self, parent):
|
||||||
"""Move a file to a new parent.
|
"""Move a file to a new parent.
|
||||||
|
@ -66,7 +66,6 @@ class UserFilterSchema(FilterSchema):
|
|||||||
SearchQuerySet()
|
SearchQuerySet()
|
||||||
.models(User)
|
.models(User)
|
||||||
.autocomplete(auto=slugify(value).replace("-", " "))
|
.autocomplete(auto=slugify(value).replace("-", " "))
|
||||||
.order_by("-last_update")
|
|
||||||
.values_list("pk", flat=True)
|
.values_list("pk", flat=True)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -33,6 +33,9 @@ class UserIndex(indexes.SearchIndex, indexes.Indexable):
|
|||||||
text = indexes.CharField(document=True, use_template=True)
|
text = indexes.CharField(document=True, use_template=True)
|
||||||
auto = indexes.EdgeNgramField(use_template=True)
|
auto = indexes.EdgeNgramField(use_template=True)
|
||||||
last_update = indexes.DateTimeField(model_attr="last_update")
|
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):
|
def get_model(self):
|
||||||
return User
|
return User
|
||||||
|
@ -108,7 +108,8 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
|
|||||||
return url;
|
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,
|
* Given a paginated endpoint, fetch all the items of this endpoint,
|
||||||
* performing multiple API calls if necessary.
|
* 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))
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
@ -306,6 +306,12 @@ a:not(.button) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: justify;
|
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 {
|
&.alert-green {
|
||||||
background-color: rgb(245, 255, 245);
|
background-color: rgb(245, 255, 245);
|
||||||
color: rgb(3, 84, 63);
|
color: rgb(3, 84, 63);
|
||||||
|
@ -77,11 +77,11 @@ function create_graph(container, data, active_user_id) {
|
|||||||
fit: true,
|
fit: true,
|
||||||
klay: {
|
klay: {
|
||||||
addUnnecessaryBendpoints: true,
|
addUnnecessaryBendpoints: true,
|
||||||
direction: 'DOWN',
|
direction: "DOWN",
|
||||||
nodePlacement: 'INTERACTIVE',
|
nodePlacement: "INTERACTIVE",
|
||||||
layoutHierarchy: true
|
layoutHierarchy: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
let active_user = cy
|
let active_user = cy
|
||||||
.getElementById(active_user_id)
|
.getElementById(active_user_id)
|
||||||
@ -178,7 +178,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
typeof depth_min === "undefined" ||
|
typeof depth_min === "undefined" ||
|
||||||
typeof depth_max === "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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,7 +196,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
godfathers_depth: get_initial_depth("godfathers_depth"),
|
godfathers_depth: get_initial_depth("godfathers_depth"),
|
||||||
godchildren_depth: get_initial_depth("godchildren_depth"),
|
godchildren_depth: get_initial_depth("godchildren_depth"),
|
||||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === 'true',
|
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||||
graph: undefined,
|
graph: undefined,
|
||||||
graph_data: {},
|
graph_data: {},
|
||||||
|
|
||||||
@ -227,7 +229,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
async screenshot() {
|
async screenshot() {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = this.graph.jpg();
|
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);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
110
core/static/user/js/user_edit.js
Normal file
110
core/static/user/js/user_edit.js
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@ -8,7 +8,12 @@
|
|||||||
<link rel="stylesheet" href="{{ scss('user/user_edit.scss') }}">
|
<link rel="stylesheet" href="{{ scss('user/user_edit.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{% block additional_js %}
|
||||||
|
<script defer src="{{ static("user/js/user_edit.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% macro profile_picture(field_name) %}
|
{% 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" x-data="camera_{{ field_name }}" >
|
||||||
<div class="profile-picture-display" :aria-busy="loading" :class="{ 'camera-error': is_camera_error }">
|
<div class="profile-picture-display" :aria-busy="loading" :class="{ 'camera-error': is_camera_error }">
|
||||||
<img
|
<img
|
||||||
@ -50,8 +55,8 @@
|
|||||||
<div>
|
<div>
|
||||||
{{ form[field_name] }}
|
{{ form[field_name] }}
|
||||||
<button class="btn btn-red" @click.prevent="delete_picture()"
|
<button class="btn btn-red" @click.prevent="delete_picture()"
|
||||||
{%- if not (user.is_root and form.instance[field_name]) -%}
|
{%- if not (this_picture and this_picture.is_owned_by(user)) -%}
|
||||||
:disabled="picture == null"
|
:disabled="!picture"
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
x-cloak
|
x-cloak
|
||||||
>
|
>
|
||||||
@ -68,128 +73,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<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", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("camera_{{ field_name }}", () => ({
|
Alpine.data(
|
||||||
can_edit_picture: false,
|
"camera_{{ field_name }}",
|
||||||
|
alpine_webcam_builder(
|
||||||
loading: false,
|
{{ default_picture }},
|
||||||
is_camera_enabled: false,
|
{{ delete_url }},
|
||||||
is_camera_error: false,
|
{{ (this_picture and this_picture.is_owned_by(user))|tojson }}
|
||||||
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;
|
|
||||||
},
|
|
||||||
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
@ -356,82 +356,6 @@ class TestUserTools:
|
|||||||
assert response.status_code == 200
|
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):
|
class TestUserIsInGroup(TestCase):
|
||||||
"""Test that the User.is_in_group() and AnonymousUser.is_in_group()
|
"""Test that the User.is_in_group() and AnonymousUser.is_in_group()
|
||||||
work as intended.
|
work as intended.
|
||||||
|
@ -22,7 +22,7 @@ class TestFetchFamilyApi(TestCase):
|
|||||||
# <- user5
|
# <- user5
|
||||||
|
|
||||||
cls.main_user = baker.make(User)
|
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.godfathers.add(*cls.users[0:3])
|
||||||
cls.main_user.godchildren.add(*cls.users[3:6])
|
cls.main_user.godchildren.add(*cls.users[3:6])
|
||||||
cls.users[1].godfathers.add(cls.users[6])
|
cls.users[1].godfathers.add(cls.users[6])
|
||||||
|
221
core/tests/test_files.py
Normal file
221
core/tests/test_files.py
Normal 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
97
core/tests/test_user.py
Normal 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
|
@ -357,7 +357,7 @@ class FileDeleteView(CanEditPropMixin, DeleteView):
|
|||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
self.object.file.delete() # Doing it here or overloading delete() is the same, so let's do it here
|
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"]
|
return self.request.GET["next"]
|
||||||
if self.object.parent is None:
|
if self.object.parent is None:
|
||||||
return reverse(
|
return reverse(
|
||||||
|
@ -85,7 +85,8 @@ def search_user(query):
|
|||||||
SearchQuerySet()
|
SearchQuerySet()
|
||||||
.models(User)
|
.models(User)
|
||||||
.autocomplete(auto=query)
|
.autocomplete(auto=query)
|
||||||
.order_by("-last_update")[:20]
|
.order_by("-last_login")
|
||||||
|
.load_all()[:20]
|
||||||
)
|
)
|
||||||
return [r.object for r in res]
|
return [r.object for r in res]
|
||||||
except TypeError:
|
except TypeError:
|
||||||
|
@ -46,6 +46,7 @@ class CustomerAdmin(SearchModelAdmin):
|
|||||||
@admin.register(BillingInfo)
|
@admin.register(BillingInfo)
|
||||||
class BillingInfoAdmin(admin.ModelAdmin):
|
class BillingInfoAdmin(admin.ModelAdmin):
|
||||||
list_display = ("first_name", "last_name", "address_1", "city", "country")
|
list_display = ("first_name", "last_name", "address_1", "city", "country")
|
||||||
|
autocomplete_fields = ("customer",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Counter)
|
@admin.register(Counter)
|
||||||
|
@ -2,6 +2,7 @@ from ajax_select import make_ajax_field
|
|||||||
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
|
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||||
|
|
||||||
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
|
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
@ -26,7 +27,11 @@ class BillingInfoForm(forms.ModelForm):
|
|||||||
"zip_code",
|
"zip_code",
|
||||||
"city",
|
"city",
|
||||||
"country",
|
"country",
|
||||||
|
"phone_number",
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
|
"phone_number": RegionalPhoneNumberWidget,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class StudentCardForm(forms.ModelForm):
|
class StudentCardForm(forms.ModelForm):
|
||||||
|
20
counter/migrations/0023_billinginfo_phone_number.py
Normal file
20
counter/migrations/0023_billinginfo_phone_number.py
Normal 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -34,6 +34,7 @@ from django.utils import timezone
|
|||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
|
|
||||||
from accounting.models import CurrencyField
|
from accounting.models import CurrencyField
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
@ -176,6 +177,14 @@ class BillingInfo(models.Model):
|
|||||||
city = models.CharField(_("City"), max_length=50)
|
city = models.CharField(_("City"), max_length=50)
|
||||||
country = CountryField(blank_label=_("Country"))
|
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):
|
def __str__(self):
|
||||||
return f"{self.first_name} {self.last_name}"
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
@ -192,6 +201,8 @@ class BillingInfo(models.Model):
|
|||||||
"ZipCode": self.zip_code,
|
"ZipCode": self.zip_code,
|
||||||
"City": self.city,
|
"City": self.city,
|
||||||
"CountryCode": self.country.numeric, # ISO-3166-1 numeric code
|
"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:
|
if self.address_2:
|
||||||
|
@ -315,6 +315,7 @@ class TestBillingInfo:
|
|||||||
"zip_code": "34301",
|
"zip_code": "34301",
|
||||||
"city": "Sète",
|
"city": "Sète",
|
||||||
"country": "FR",
|
"country": "FR",
|
||||||
|
"phone_number": "0612345678",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_edit_infos(self, client: Client, payload: dict):
|
def test_edit_infos(self, client: Client, payload: dict):
|
||||||
@ -356,7 +357,7 @@ class TestBillingInfo:
|
|||||||
for key, val in payload.items():
|
for key, val in payload.items():
|
||||||
assert getattr(infos, key) == val
|
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()
|
user = subscriber_user.make()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
# address_1, zip_code and country are missing
|
# address_1, zip_code and country are missing
|
||||||
@ -391,6 +392,60 @@ class TestBillingInfo:
|
|||||||
)
|
)
|
||||||
assert response.status_code == expected_code
|
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):
|
class TestBarmanConnection(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from ninja import ModelSchema, Schema
|
from ninja import ModelSchema, Schema
|
||||||
from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter
|
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
|
from counter.models import BillingInfo
|
||||||
|
|
||||||
|
|
||||||
@ -31,3 +36,8 @@ class BillingInfoSchema(ModelSchema):
|
|||||||
"country",
|
"country",
|
||||||
]
|
]
|
||||||
fields_optional = ["customer"]
|
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")]
|
||||||
|
@ -42,7 +42,16 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.req_state = res.ok
|
this.req_state = res.ok
|
||||||
? BillingInfoReqState.SUCCESS
|
? BillingInfoReqState.SUCCESS
|
||||||
: BillingInfoReqState.FAILURE;
|
: 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();
|
Alpine.store("billing_inputs").fill();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -98,13 +98,20 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
{% if must_fill_billing_infos %}
|
{% if billing_infos_state == BillingInfoState.EMPTY %}
|
||||||
<p>
|
<div class="alert alert-yellow">
|
||||||
<i>
|
{% trans %}You must fill your billing infos if you want to pay with your credit
|
||||||
{% trans %}You must fill your billing infos if you want to pay with your credit
|
card{% endtrans %}
|
||||||
card{% endtrans %}
|
</div>
|
||||||
</i>
|
{% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %}
|
||||||
</p>
|
<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 %}
|
{% endif %}
|
||||||
<form method="post" action="{{ settings.SITH_EBOUTIC_ET_URL }}" name="bank-pay-form">
|
<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)">
|
<template x-data x-for="[key, value] in Object.entries($store.billing_inputs.data)">
|
||||||
@ -113,7 +120,7 @@
|
|||||||
<input
|
<input
|
||||||
type="submit"
|
type="submit"
|
||||||
id="bank-submit-button"
|
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 %}"
|
value="{% trans %}Pay with credit card{% endtrans %}"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from cryptography.exceptions import InvalidSignature
|
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)
|
return render(request, "eboutic/eboutic_payment_result.jinja", context)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingInfoState(Enum):
|
||||||
|
VALID = 1
|
||||||
|
EMPTY = 2
|
||||||
|
MISSING_PHONE_NUMBER = 3
|
||||||
|
|
||||||
|
|
||||||
class EbouticCommand(LoginRequiredMixin, TemplateView):
|
class EbouticCommand(LoginRequiredMixin, TemplateView):
|
||||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
template_name = "eboutic/eboutic_makecommand.jinja"
|
||||||
basket: Basket
|
basket: Basket
|
||||||
@ -130,9 +137,16 @@ class EbouticCommand(LoginRequiredMixin, TemplateView):
|
|||||||
default_billing_info = customer.billing_infos
|
default_billing_info = customer.billing_infos
|
||||||
else:
|
else:
|
||||||
kwargs["customer_amount"] = None
|
kwargs["customer_amount"] = None
|
||||||
kwargs["must_fill_billing_infos"] = default_billing_info is None
|
# make the enum available in the template
|
||||||
if not kwargs["must_fill_billing_infos"]:
|
kwargs["BillingInfoState"] = BillingInfoState
|
||||||
# the user has already filled its billing_infos, thus we can
|
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
|
# get it without expecting an error
|
||||||
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
|
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
|
||||||
kwargs["basket"] = self.basket
|
kwargs["basket"] = self.basket
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"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"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Skia <skia@libskia.so>\n"
|
"Last-Translator: Skia <skia@libskia.so>\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\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:62 accounting/models.py:103 accounting/models.py:136
|
||||||
#: accounting/models.py:203 club/models.py:54 com/models.py:274
|
#: accounting/models.py:203 club/models.py:54 com/models.py:274
|
||||||
#: com/models.py:293 counter/models.py:209 counter/models.py:242
|
#: com/models.py:293 counter/models.py:213 counter/models.py:246
|
||||||
#: counter/models.py:377 forum/models.py:59 launderette/models.py:29
|
#: counter/models.py:381 forum/models.py:59 launderette/models.py:29
|
||||||
#: launderette/models.py:84 launderette/models.py:122 stock/models.py:36
|
#: launderette/models.py:84 launderette/models.py:122 stock/models.py:36
|
||||||
#: stock/models.py:57 stock/models.py:97 stock/models.py:125
|
#: stock/models.py:57 stock/models.py:97 stock/models.py:125
|
||||||
msgid "name"
|
msgid "name"
|
||||||
@ -66,8 +66,8 @@ msgid "account number"
|
|||||||
msgstr "numéro de compte"
|
msgstr "numéro de compte"
|
||||||
|
|
||||||
#: accounting/models.py:109 accounting/models.py:140 club/models.py:344
|
#: 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
|
#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:269
|
||||||
#: counter/models.py:379 trombi/models.py:210
|
#: counter/models.py:383 trombi/models.py:210
|
||||||
msgid "club"
|
msgid "club"
|
||||||
msgstr "club"
|
msgstr "club"
|
||||||
|
|
||||||
@ -88,12 +88,12 @@ msgstr "Compte club"
|
|||||||
msgid "%(club_account)s on %(bank_account)s"
|
msgid "%(club_account)s on %(bank_account)s"
|
||||||
msgstr "%(club_account)s sur %(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
|
#: election/models.py:16 launderette/models.py:179
|
||||||
msgid "start date"
|
msgid "start date"
|
||||||
msgstr "date de début"
|
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
|
#: election/models.py:17
|
||||||
msgid "end date"
|
msgid "end date"
|
||||||
msgstr "date de fin"
|
msgstr "date de fin"
|
||||||
@ -106,8 +106,8 @@ msgstr "est fermé"
|
|||||||
msgid "club account"
|
msgid "club account"
|
||||||
msgstr "compte club"
|
msgstr "compte club"
|
||||||
|
|
||||||
#: accounting/models.py:212 accounting/models.py:272 counter/models.py:56
|
#: accounting/models.py:212 accounting/models.py:272 counter/models.py:57
|
||||||
#: counter/models.py:583
|
#: counter/models.py:587
|
||||||
msgid "amount"
|
msgid "amount"
|
||||||
msgstr "montant"
|
msgstr "montant"
|
||||||
|
|
||||||
@ -127,20 +127,20 @@ msgstr "numéro"
|
|||||||
msgid "journal"
|
msgid "journal"
|
||||||
msgstr "classeur"
|
msgstr "classeur"
|
||||||
|
|
||||||
#: accounting/models.py:273 core/models.py:940 core/models.py:1442
|
#: accounting/models.py:273 core/models.py:940 core/models.py:1460
|
||||||
#: core/models.py:1487 core/models.py:1516 core/models.py:1540
|
#: core/models.py:1505 core/models.py:1534 core/models.py:1558
|
||||||
#: counter/models.py:593 counter/models.py:686 counter/models.py:896
|
#: counter/models.py:597 counter/models.py:690 counter/models.py:900
|
||||||
#: eboutic/models.py:57 eboutic/models.py:173 forum/models.py:311
|
#: eboutic/models.py:57 eboutic/models.py:173 forum/models.py:311
|
||||||
#: forum/models.py:412 stock/models.py:96
|
#: forum/models.py:412 stock/models.py:96
|
||||||
msgid "date"
|
msgid "date"
|
||||||
msgstr "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
|
#: pedagogy/models.py:207 stock/models.py:99
|
||||||
msgid "comment"
|
msgid "comment"
|
||||||
msgstr "commentaire"
|
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
|
#: subscription/models.py:56
|
||||||
msgid "payment method"
|
msgid "payment method"
|
||||||
msgstr "méthode de paiement"
|
msgstr "méthode de paiement"
|
||||||
@ -166,8 +166,8 @@ msgid "accounting type"
|
|||||||
msgstr "type comptable"
|
msgstr "type comptable"
|
||||||
|
|
||||||
#: accounting/models.py:311 accounting/models.py:450 accounting/models.py:483
|
#: accounting/models.py:311 accounting/models.py:450 accounting/models.py:483
|
||||||
#: accounting/models.py:515 core/models.py:1515 core/models.py:1541
|
#: accounting/models.py:515 core/models.py:1533 core/models.py:1559
|
||||||
#: counter/models.py:652
|
#: counter/models.py:656
|
||||||
msgid "label"
|
msgid "label"
|
||||||
msgstr "étiquette"
|
msgstr "étiquette"
|
||||||
|
|
||||||
@ -266,7 +266,7 @@ msgstr ""
|
|||||||
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
|
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
|
||||||
"standard"
|
"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"
|
msgid "code"
|
||||||
msgstr "code"
|
msgstr "code"
|
||||||
|
|
||||||
@ -370,7 +370,7 @@ msgstr "Compte en banque : "
|
|||||||
#: core/templates/core/user_account_detail.jinja:66
|
#: core/templates/core/user_account_detail.jinja:66
|
||||||
#: core/templates/core/user_clubs.jinja:34
|
#: core/templates/core/user_clubs.jinja:34
|
||||||
#: core/templates/core/user_clubs.jinja:63
|
#: 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
|
#: core/templates/core/user_preferences.jinja:48
|
||||||
#: counter/templates/counter/last_ops.jinja:35
|
#: counter/templates/counter/last_ops.jinja:35
|
||||||
#: counter/templates/counter/last_ops.jinja:65
|
#: 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"
|
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"
|
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"
|
msgid "Begin date"
|
||||||
msgstr "Date de début"
|
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
|
#: election/views.py:167 subscription/views.py:38
|
||||||
msgid "End date"
|
msgid "End date"
|
||||||
msgstr "Date de fin"
|
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"
|
msgid "A club with that unix_name already exists"
|
||||||
msgstr "Un club avec ce nom UNIX existe déjà."
|
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
|
#: eboutic/models.py:53 eboutic/models.py:169 election/models.py:183
|
||||||
#: launderette/models.py:136 launderette/models.py:198 sas/models.py:270
|
#: launderette/models.py:136 launderette/models.py:198 sas/models.py:270
|
||||||
#: trombi/models.py:206
|
#: trombi/models.py:206
|
||||||
@ -1070,8 +1070,8 @@ msgstr "nom d'utilisateur"
|
|||||||
msgid "role"
|
msgid "role"
|
||||||
msgstr "rôle"
|
msgstr "rôle"
|
||||||
|
|
||||||
#: club/models.py:358 core/models.py:89 counter/models.py:210
|
#: club/models.py:358 core/models.py:89 counter/models.py:214
|
||||||
#: counter/models.py:243 election/models.py:13 election/models.py:115
|
#: counter/models.py:247 election/models.py:13 election/models.py:115
|
||||||
#: election/models.py:188 forum/models.py:60 forum/models.py:244
|
#: election/models.py:188 forum/models.py:60 forum/models.py:244
|
||||||
msgid "description"
|
msgid "description"
|
||||||
msgstr "description"
|
msgstr "description"
|
||||||
@ -1440,7 +1440,7 @@ msgstr "résumé"
|
|||||||
msgid "content"
|
msgid "content"
|
||||||
msgstr "contenu"
|
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
|
#: launderette/models.py:130 launderette/models.py:181 stock/models.py:74
|
||||||
#: stock/models.py:129
|
#: stock/models.py:129
|
||||||
msgid "type"
|
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:110
|
||||||
#: core/templates/core/user_detail.jinja:112
|
#: core/templates/core/user_detail.jinja:112
|
||||||
#: core/templates/core/user_detail.jinja:113
|
#: 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:132
|
||||||
#: election/templates/election/election_detail.jinja:134
|
#: election/templates/election/election_detail.jinja:134
|
||||||
#: forum/templates/forum/macros.jinja:104
|
#: forum/templates/forum/macros.jinja:104
|
||||||
@ -2250,7 +2250,7 @@ msgstr "avoir une notification pour chaque rechargement"
|
|||||||
msgid "file name"
|
msgid "file name"
|
||||||
msgstr "nom du fichier"
|
msgstr "nom du fichier"
|
||||||
|
|
||||||
#: core/models.py:899 core/models.py:1234
|
#: core/models.py:899 core/models.py:1252
|
||||||
msgid "parent"
|
msgid "parent"
|
||||||
msgstr "parent"
|
msgstr "parent"
|
||||||
|
|
||||||
@ -2266,11 +2266,11 @@ msgstr "miniature"
|
|||||||
msgid "owner"
|
msgid "owner"
|
||||||
msgstr "propriétaire"
|
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"
|
msgid "edit group"
|
||||||
msgstr "groupe d'édition"
|
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"
|
msgid "view group"
|
||||||
msgstr "groupe de vue"
|
msgstr "groupe de vue"
|
||||||
|
|
||||||
@ -2316,11 +2316,11 @@ msgstr "Un fichier de ce nom existe déjà"
|
|||||||
msgid "You must provide a file"
|
msgid "You must provide a file"
|
||||||
msgstr "Vous devez fournir un fichier"
|
msgstr "Vous devez fournir un fichier"
|
||||||
|
|
||||||
#: core/models.py:1217
|
#: core/models.py:1235
|
||||||
msgid "page unix name"
|
msgid "page unix name"
|
||||||
msgstr "nom unix de la page"
|
msgstr "nom unix de la page"
|
||||||
|
|
||||||
#: core/models.py:1223
|
#: core/models.py:1241
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid page name. This value may contain only unaccented letters, "
|
"Enter a valid page name. This value may contain only unaccented letters, "
|
||||||
"numbers and ./+/-/_ characters."
|
"numbers and ./+/-/_ characters."
|
||||||
@ -2328,55 +2328,55 @@ msgstr ""
|
|||||||
"Entrez un nom de page correct. Uniquement des lettres non accentuées, "
|
"Entrez un nom de page correct. Uniquement des lettres non accentuées, "
|
||||||
"numéros, et ./+/-/_"
|
"numéros, et ./+/-/_"
|
||||||
|
|
||||||
#: core/models.py:1241
|
#: core/models.py:1259
|
||||||
msgid "page name"
|
msgid "page name"
|
||||||
msgstr "nom de la page"
|
msgstr "nom de la page"
|
||||||
|
|
||||||
#: core/models.py:1246
|
#: core/models.py:1264
|
||||||
msgid "owner group"
|
msgid "owner group"
|
||||||
msgstr "groupe propriétaire"
|
msgstr "groupe propriétaire"
|
||||||
|
|
||||||
#: core/models.py:1259
|
#: core/models.py:1277
|
||||||
msgid "lock user"
|
msgid "lock user"
|
||||||
msgstr "utilisateur bloquant"
|
msgstr "utilisateur bloquant"
|
||||||
|
|
||||||
#: core/models.py:1266
|
#: core/models.py:1284
|
||||||
msgid "lock_timeout"
|
msgid "lock_timeout"
|
||||||
msgstr "décompte du déblocage"
|
msgstr "décompte du déblocage"
|
||||||
|
|
||||||
#: core/models.py:1316
|
#: core/models.py:1334
|
||||||
msgid "Duplicate page"
|
msgid "Duplicate page"
|
||||||
msgstr "Une page de ce nom existe déjà"
|
msgstr "Une page de ce nom existe déjà"
|
||||||
|
|
||||||
#: core/models.py:1319
|
#: core/models.py:1337
|
||||||
msgid "Loop in page tree"
|
msgid "Loop in page tree"
|
||||||
msgstr "Boucle dans l'arborescence des pages"
|
msgstr "Boucle dans l'arborescence des pages"
|
||||||
|
|
||||||
#: core/models.py:1439
|
#: core/models.py:1457
|
||||||
msgid "revision"
|
msgid "revision"
|
||||||
msgstr "révision"
|
msgstr "révision"
|
||||||
|
|
||||||
#: core/models.py:1440
|
#: core/models.py:1458
|
||||||
msgid "page title"
|
msgid "page title"
|
||||||
msgstr "titre de la page"
|
msgstr "titre de la page"
|
||||||
|
|
||||||
#: core/models.py:1441
|
#: core/models.py:1459
|
||||||
msgid "page content"
|
msgid "page content"
|
||||||
msgstr "contenu de la page"
|
msgstr "contenu de la page"
|
||||||
|
|
||||||
#: core/models.py:1482
|
#: core/models.py:1500
|
||||||
msgid "url"
|
msgid "url"
|
||||||
msgstr "url"
|
msgstr "url"
|
||||||
|
|
||||||
#: core/models.py:1483
|
#: core/models.py:1501
|
||||||
msgid "param"
|
msgid "param"
|
||||||
msgstr "param"
|
msgstr "param"
|
||||||
|
|
||||||
#: core/models.py:1488
|
#: core/models.py:1506
|
||||||
msgid "viewed"
|
msgid "viewed"
|
||||||
msgstr "vue"
|
msgstr "vue"
|
||||||
|
|
||||||
#: core/models.py:1546
|
#: core/models.py:1564
|
||||||
msgid "operation type"
|
msgid "operation type"
|
||||||
msgstr "type d'opération"
|
msgstr "type d'opération"
|
||||||
|
|
||||||
@ -2474,7 +2474,7 @@ msgstr "Forum"
|
|||||||
msgid "Gallery"
|
msgid "Gallery"
|
||||||
msgstr "Photos"
|
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
|
#: counter/templates/counter/counter_list.jinja:11
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja:4
|
#: eboutic/templates/eboutic/eboutic_main.jinja:4
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja:22
|
#: eboutic/templates/eboutic/eboutic_main.jinja:22
|
||||||
@ -2693,7 +2693,7 @@ msgid "Edit group"
|
|||||||
msgstr "Éditer le groupe"
|
msgstr "Éditer le groupe"
|
||||||
|
|
||||||
#: core/templates/core/group_edit.jinja:9
|
#: 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
|
#: core/templates/core/user_group.jinja:13
|
||||||
#: pedagogy/templates/pedagogy/uv_edit.jinja:36
|
#: pedagogy/templates/pedagogy/uv_edit.jinja:36
|
||||||
msgid "Update"
|
msgid "Update"
|
||||||
@ -3184,39 +3184,35 @@ msgstr "Aucun cadeau donné pour l'instant"
|
|||||||
msgid "Edit user"
|
msgid "Edit user"
|
||||||
msgstr "Éditer l'utilisateur"
|
msgstr "Éditer l'utilisateur"
|
||||||
|
|
||||||
#: core/templates/core/user_edit.jinja:36
|
#: core/templates/core/user_edit.jinja:41
|
||||||
msgid "Enable camera"
|
msgid "Enable camera"
|
||||||
msgstr "Activer la caméra"
|
msgstr "Activer la caméra"
|
||||||
|
|
||||||
#: core/templates/core/user_edit.jinja:44
|
#: core/templates/core/user_edit.jinja:49
|
||||||
msgid "Take a picture"
|
msgid "Take a picture"
|
||||||
msgstr "Prendre une photo"
|
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"
|
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"
|
msgstr "Pour changer votre photo de profil, demandez à un membre de l'AE"
|
||||||
|
|
||||||
#: core/templates/core/user_edit.jinja:173
|
#: core/templates/core/user_edit.jinja:98
|
||||||
msgid "captured"
|
|
||||||
msgstr "capturé"
|
|
||||||
|
|
||||||
#: core/templates/core/user_edit.jinja:196
|
|
||||||
msgid "Edit user profile"
|
msgid "Edit user profile"
|
||||||
msgstr "Éditer le profil de l'utilisateur"
|
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"
|
msgid "Change my password"
|
||||||
msgstr "Changer mon mot de passe"
|
msgstr "Changer mon mot de passe"
|
||||||
|
|
||||||
#: core/templates/core/user_edit.jinja:263
|
#: core/templates/core/user_edit.jinja:165
|
||||||
msgid "Change user password"
|
msgid "Change user password"
|
||||||
msgstr "Changer le mot de passe"
|
msgstr "Changer le mot de passe"
|
||||||
|
|
||||||
#: core/templates/core/user_edit.jinja:273
|
#: core/templates/core/user_edit.jinja:175
|
||||||
msgid "Username:"
|
msgid "Username:"
|
||||||
msgstr "Nom d'utilisateur : "
|
msgstr "Nom d'utilisateur : "
|
||||||
|
|
||||||
#: core/templates/core/user_edit.jinja:276
|
#: core/templates/core/user_edit.jinja:178
|
||||||
msgid "Account number:"
|
msgid "Account number:"
|
||||||
msgstr "Numéro de compte : "
|
msgstr "Numéro de compte : "
|
||||||
|
|
||||||
@ -3350,7 +3346,7 @@ msgstr "Achats"
|
|||||||
msgid "Product top 10"
|
msgid "Product top 10"
|
||||||
msgstr "Top 10 produits"
|
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"
|
msgid "Product"
|
||||||
msgstr "Produit"
|
msgstr "Produit"
|
||||||
|
|
||||||
@ -3395,7 +3391,7 @@ msgstr "Cotisations"
|
|||||||
msgid "Subscription stats"
|
msgid "Subscription stats"
|
||||||
msgstr "Statistiques de cotisation"
|
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
|
#: counter/views.py:728
|
||||||
msgid "Counters"
|
msgid "Counters"
|
||||||
msgstr "Comptoirs"
|
msgstr "Comptoirs"
|
||||||
@ -3654,7 +3650,7 @@ msgstr "Parrain / Marraine"
|
|||||||
msgid "Godchild"
|
msgid "Godchild"
|
||||||
msgstr "Fillot / Fillote"
|
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"
|
msgid "Select user"
|
||||||
msgstr "Choisir un utilisateur"
|
msgstr "Choisir un utilisateur"
|
||||||
|
|
||||||
@ -3713,24 +3709,24 @@ msgstr "Photos"
|
|||||||
msgid "Galaxy"
|
msgid "Galaxy"
|
||||||
msgstr "Galaxie"
|
msgstr "Galaxie"
|
||||||
|
|
||||||
#: counter/apps.py:30 counter/models.py:403 counter/models.py:857
|
#: counter/apps.py:30 counter/models.py:407 counter/models.py:861
|
||||||
#: counter/models.py:893 launderette/models.py:32 stock/models.py:39
|
#: counter/models.py:897 launderette/models.py:32 stock/models.py:39
|
||||||
msgid "counter"
|
msgid "counter"
|
||||||
msgstr "comptoir"
|
msgstr "comptoir"
|
||||||
|
|
||||||
#: counter/forms.py:48
|
#: counter/forms.py:49
|
||||||
msgid "This UID is invalid"
|
msgid "This UID is invalid"
|
||||||
msgstr "Cet UID est invalide"
|
msgstr "Cet UID est invalide"
|
||||||
|
|
||||||
#: counter/forms.py:89
|
#: counter/forms.py:90
|
||||||
msgid "User not found"
|
msgid "User not found"
|
||||||
msgstr "Utilisateur non trouvé"
|
msgstr "Utilisateur non trouvé"
|
||||||
|
|
||||||
#: counter/forms.py:145
|
#: counter/forms.py:146
|
||||||
msgid "Parent product"
|
msgid "Parent product"
|
||||||
msgstr "Produit parent"
|
msgstr "Produit parent"
|
||||||
|
|
||||||
#: counter/forms.py:151
|
#: counter/forms.py:152
|
||||||
msgid "Buying groups"
|
msgid "Buying groups"
|
||||||
msgstr "Groupes d'achat"
|
msgstr "Groupes d'achat"
|
||||||
|
|
||||||
@ -3738,165 +3734,169 @@ msgstr "Groupes d'achat"
|
|||||||
msgid "Ecocup regularization"
|
msgid "Ecocup regularization"
|
||||||
msgstr "Régularization des ecocups"
|
msgstr "Régularization des ecocups"
|
||||||
|
|
||||||
#: counter/models.py:55
|
#: counter/models.py:56
|
||||||
msgid "account id"
|
msgid "account id"
|
||||||
msgstr "numéro de compte"
|
msgstr "numéro de compte"
|
||||||
|
|
||||||
#: counter/models.py:57
|
#: counter/models.py:58
|
||||||
msgid "recorded product"
|
msgid "recorded product"
|
||||||
msgstr "produits consignés"
|
msgstr "produits consignés"
|
||||||
|
|
||||||
#: counter/models.py:60
|
#: counter/models.py:61
|
||||||
msgid "customer"
|
msgid "customer"
|
||||||
msgstr "client"
|
msgstr "client"
|
||||||
|
|
||||||
#: counter/models.py:61
|
#: counter/models.py:62
|
||||||
msgid "customers"
|
msgid "customers"
|
||||||
msgstr "clients"
|
msgstr "clients"
|
||||||
|
|
||||||
#: counter/models.py:73 counter/views.py:309
|
#: counter/models.py:74 counter/views.py:309
|
||||||
msgid "Not enough money"
|
msgid "Not enough money"
|
||||||
msgstr "Solde insuffisant"
|
msgstr "Solde insuffisant"
|
||||||
|
|
||||||
#: counter/models.py:171
|
#: counter/models.py:172
|
||||||
msgid "First name"
|
msgid "First name"
|
||||||
msgstr "Prénom"
|
msgstr "Prénom"
|
||||||
|
|
||||||
#: counter/models.py:172
|
#: counter/models.py:173
|
||||||
msgid "Last name"
|
msgid "Last name"
|
||||||
msgstr "Nom de famille"
|
msgstr "Nom de famille"
|
||||||
|
|
||||||
#: counter/models.py:173
|
#: counter/models.py:174
|
||||||
msgid "Address 1"
|
msgid "Address 1"
|
||||||
msgstr "Adresse 1"
|
msgstr "Adresse 1"
|
||||||
|
|
||||||
#: counter/models.py:174
|
#: counter/models.py:175
|
||||||
msgid "Address 2"
|
msgid "Address 2"
|
||||||
msgstr "Adresse 2"
|
msgstr "Adresse 2"
|
||||||
|
|
||||||
#: counter/models.py:175
|
#: counter/models.py:176
|
||||||
msgid "Zip code"
|
msgid "Zip code"
|
||||||
msgstr "Code postal"
|
msgstr "Code postal"
|
||||||
|
|
||||||
#: counter/models.py:176
|
#: counter/models.py:177
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr "Ville"
|
msgstr "Ville"
|
||||||
|
|
||||||
#: counter/models.py:177
|
#: counter/models.py:178
|
||||||
msgid "Country"
|
msgid "Country"
|
||||||
msgstr "Pays"
|
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"
|
msgid "product type"
|
||||||
msgstr "type du produit"
|
msgstr "type du produit"
|
||||||
|
|
||||||
#: counter/models.py:253
|
#: counter/models.py:257
|
||||||
msgid "purchase price"
|
msgid "purchase price"
|
||||||
msgstr "prix d'achat"
|
msgstr "prix d'achat"
|
||||||
|
|
||||||
#: counter/models.py:254
|
#: counter/models.py:258
|
||||||
msgid "selling price"
|
msgid "selling price"
|
||||||
msgstr "prix de vente"
|
msgstr "prix de vente"
|
||||||
|
|
||||||
#: counter/models.py:255
|
#: counter/models.py:259
|
||||||
msgid "special selling price"
|
msgid "special selling price"
|
||||||
msgstr "prix de vente spécial"
|
msgstr "prix de vente spécial"
|
||||||
|
|
||||||
#: counter/models.py:262
|
#: counter/models.py:266
|
||||||
msgid "icon"
|
msgid "icon"
|
||||||
msgstr "icône"
|
msgstr "icône"
|
||||||
|
|
||||||
#: counter/models.py:267
|
#: counter/models.py:271
|
||||||
msgid "limit age"
|
msgid "limit age"
|
||||||
msgstr "âge limite"
|
msgstr "âge limite"
|
||||||
|
|
||||||
#: counter/models.py:268
|
#: counter/models.py:272
|
||||||
msgid "tray price"
|
msgid "tray price"
|
||||||
msgstr "prix plateau"
|
msgstr "prix plateau"
|
||||||
|
|
||||||
#: counter/models.py:272
|
#: counter/models.py:276
|
||||||
msgid "parent product"
|
msgid "parent product"
|
||||||
msgstr "produit parent"
|
msgstr "produit parent"
|
||||||
|
|
||||||
#: counter/models.py:278
|
#: counter/models.py:282
|
||||||
msgid "buying groups"
|
msgid "buying groups"
|
||||||
msgstr "groupe d'achat"
|
msgstr "groupe d'achat"
|
||||||
|
|
||||||
#: counter/models.py:280 election/models.py:50
|
#: counter/models.py:284 election/models.py:50
|
||||||
msgid "archived"
|
msgid "archived"
|
||||||
msgstr "archivé"
|
msgstr "archivé"
|
||||||
|
|
||||||
#: counter/models.py:283 counter/models.py:993
|
#: counter/models.py:287 counter/models.py:997
|
||||||
msgid "product"
|
msgid "product"
|
||||||
msgstr "produit"
|
msgstr "produit"
|
||||||
|
|
||||||
#: counter/models.py:382
|
#: counter/models.py:386
|
||||||
msgid "products"
|
msgid "products"
|
||||||
msgstr "produits"
|
msgstr "produits"
|
||||||
|
|
||||||
#: counter/models.py:385
|
#: counter/models.py:389
|
||||||
msgid "counter type"
|
msgid "counter type"
|
||||||
msgstr "type de comptoir"
|
msgstr "type de comptoir"
|
||||||
|
|
||||||
#: counter/models.py:387
|
#: counter/models.py:391
|
||||||
msgid "Bar"
|
msgid "Bar"
|
||||||
msgstr "Bar"
|
msgstr "Bar"
|
||||||
|
|
||||||
#: counter/models.py:387
|
#: counter/models.py:391
|
||||||
msgid "Office"
|
msgid "Office"
|
||||||
msgstr "Bureau"
|
msgstr "Bureau"
|
||||||
|
|
||||||
#: counter/models.py:390
|
#: counter/models.py:394
|
||||||
msgid "sellers"
|
msgid "sellers"
|
||||||
msgstr "vendeurs"
|
msgstr "vendeurs"
|
||||||
|
|
||||||
#: counter/models.py:398 launderette/models.py:192
|
#: counter/models.py:402 launderette/models.py:192
|
||||||
msgid "token"
|
msgid "token"
|
||||||
msgstr "jeton"
|
msgstr "jeton"
|
||||||
|
|
||||||
#: counter/models.py:601
|
#: counter/models.py:605
|
||||||
msgid "bank"
|
msgid "bank"
|
||||||
msgstr "banque"
|
msgstr "banque"
|
||||||
|
|
||||||
#: counter/models.py:603 counter/models.py:693
|
#: counter/models.py:607 counter/models.py:697
|
||||||
msgid "is validated"
|
msgid "is validated"
|
||||||
msgstr "est validé"
|
msgstr "est validé"
|
||||||
|
|
||||||
#: counter/models.py:606
|
#: counter/models.py:610
|
||||||
msgid "refilling"
|
msgid "refilling"
|
||||||
msgstr "rechargement"
|
msgstr "rechargement"
|
||||||
|
|
||||||
#: counter/models.py:670 eboutic/models.py:227
|
#: counter/models.py:674 eboutic/models.py:227
|
||||||
msgid "unit price"
|
msgid "unit price"
|
||||||
msgstr "prix unitaire"
|
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"
|
msgid "quantity"
|
||||||
msgstr "quantité"
|
msgstr "quantité"
|
||||||
|
|
||||||
#: counter/models.py:690
|
#: counter/models.py:694
|
||||||
msgid "Sith account"
|
msgid "Sith account"
|
||||||
msgstr "Compte utilisateur"
|
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
|
#: sith/settings.py:430
|
||||||
msgid "Credit card"
|
msgid "Credit card"
|
||||||
msgstr "Carte bancaire"
|
msgstr "Carte bancaire"
|
||||||
|
|
||||||
#: counter/models.py:696
|
#: counter/models.py:700
|
||||||
msgid "selling"
|
msgid "selling"
|
||||||
msgstr "vente"
|
msgstr "vente"
|
||||||
|
|
||||||
#: counter/models.py:800
|
#: counter/models.py:804
|
||||||
msgid "Unknown event"
|
msgid "Unknown event"
|
||||||
msgstr "Événement inconnu"
|
msgstr "Événement inconnu"
|
||||||
|
|
||||||
#: counter/models.py:801
|
#: counter/models.py:805
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Eticket bought for the event %(event)s"
|
msgid "Eticket bought for the event %(event)s"
|
||||||
msgstr "Eticket acheté pour l'événement %(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
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
"You bought an eticket for the event %(event)s.\n"
|
"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 "
|
"Vous pouvez également retrouver tous vos e-tickets sur votre page de compte "
|
||||||
"%(url)s."
|
"%(url)s."
|
||||||
|
|
||||||
#: counter/models.py:862
|
#: counter/models.py:866
|
||||||
msgid "last activity date"
|
msgid "last activity date"
|
||||||
msgstr "dernière activité"
|
msgstr "dernière activité"
|
||||||
|
|
||||||
#: counter/models.py:865
|
#: counter/models.py:869
|
||||||
msgid "permanency"
|
msgid "permanency"
|
||||||
msgstr "permanence"
|
msgstr "permanence"
|
||||||
|
|
||||||
#: counter/models.py:898
|
#: counter/models.py:902
|
||||||
msgid "emptied"
|
msgid "emptied"
|
||||||
msgstr "coffre vidée"
|
msgstr "coffre vidée"
|
||||||
|
|
||||||
#: counter/models.py:901
|
#: counter/models.py:905
|
||||||
msgid "cash register summary"
|
msgid "cash register summary"
|
||||||
msgstr "relevé de caisse"
|
msgstr "relevé de caisse"
|
||||||
|
|
||||||
#: counter/models.py:969
|
#: counter/models.py:973
|
||||||
msgid "cash summary"
|
msgid "cash summary"
|
||||||
msgstr "relevé"
|
msgstr "relevé"
|
||||||
|
|
||||||
#: counter/models.py:972
|
#: counter/models.py:976
|
||||||
msgid "value"
|
msgid "value"
|
||||||
msgstr "valeur"
|
msgstr "valeur"
|
||||||
|
|
||||||
#: counter/models.py:975
|
#: counter/models.py:979
|
||||||
msgid "check"
|
msgid "check"
|
||||||
msgstr "chèque"
|
msgstr "chèque"
|
||||||
|
|
||||||
#: counter/models.py:977
|
#: counter/models.py:981
|
||||||
msgid "True if this is a bank check, else False"
|
msgid "True if this is a bank check, else False"
|
||||||
msgstr "Vrai si c'est un chèque, sinon Faux."
|
msgstr "Vrai si c'est un chèque, sinon Faux."
|
||||||
|
|
||||||
#: counter/models.py:981
|
#: counter/models.py:985
|
||||||
msgid "cash register summary item"
|
msgid "cash register summary item"
|
||||||
msgstr "élément de relevé de caisse"
|
msgstr "élément de relevé de caisse"
|
||||||
|
|
||||||
#: counter/models.py:997
|
#: counter/models.py:1001
|
||||||
msgid "banner"
|
msgid "banner"
|
||||||
msgstr "bannière"
|
msgstr "bannière"
|
||||||
|
|
||||||
#: counter/models.py:999
|
#: counter/models.py:1003
|
||||||
msgid "event date"
|
msgid "event date"
|
||||||
msgstr "date de l'événement"
|
msgstr "date de l'événement"
|
||||||
|
|
||||||
#: counter/models.py:1001
|
#: counter/models.py:1005
|
||||||
msgid "event title"
|
msgid "event title"
|
||||||
msgstr "titre de l'événement"
|
msgstr "titre de l'événement"
|
||||||
|
|
||||||
#: counter/models.py:1003
|
#: counter/models.py:1007
|
||||||
msgid "secret"
|
msgid "secret"
|
||||||
msgstr "secret"
|
msgstr "secret"
|
||||||
|
|
||||||
#: counter/models.py:1042
|
#: counter/models.py:1046
|
||||||
msgid "uid"
|
msgid "uid"
|
||||||
msgstr "uid"
|
msgstr "uid"
|
||||||
|
|
||||||
#: counter/models.py:1047
|
#: counter/models.py:1051
|
||||||
msgid "student cards"
|
msgid "student cards"
|
||||||
msgstr "cartes étudiante"
|
msgstr "cartes étudiante"
|
||||||
|
|
||||||
@ -4439,40 +4439,58 @@ msgstr "Solde restant : "
|
|||||||
msgid "Billing information"
|
msgid "Billing information"
|
||||||
msgstr "Informations de facturation"
|
msgstr "Informations de facturation"
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:104
|
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:103
|
||||||
msgid ""
|
msgid ""
|
||||||
"You must fill your billing infos if you want to pay with your credit\n"
|
"You must fill your billing infos if you want to pay with your credit\n"
|
||||||
" card"
|
" card"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
|
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
|
||||||
"par carte bancaire"
|
"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"
|
msgid "Pay with credit card"
|
||||||
msgstr "Payer avec une carte bancaire"
|
msgstr "Payer avec une carte bancaire"
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:122
|
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:129
|
||||||
msgid ""
|
msgid ""
|
||||||
"AE account payment disabled because your basket contains refilling items."
|
"AE account payment disabled because your basket contains refilling items."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
|
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
|
||||||
"rechargement."
|
"rechargement."
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:124
|
#: eboutic/templates/eboutic/eboutic_makecommand.jinja:131
|
||||||
msgid ""
|
msgid ""
|
||||||
"AE account payment disabled because you do not have enough money remaining."
|
"AE account payment disabled because you do not have enough money remaining."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
|
"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"
|
msgid "Pay with Sith account"
|
||||||
msgstr "Payer avec un compte AE"
|
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"
|
msgid "Billing info registration success"
|
||||||
msgstr "Informations de facturation enregistrées"
|
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"
|
msgid "Billing info registration failure"
|
||||||
msgstr "Echec de l'enregistrement des informations de facturation."
|
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
|
#, python-format
|
||||||
msgid "Maximum characters: %(max_length)s"
|
msgid "Maximum characters: %(max_length)s"
|
||||||
msgstr "Nombre de caractères max: %(max_length)s"
|
msgstr "Nombre de caractères max: %(max_length)s"
|
||||||
|
|
||||||
|
#~ msgid "captured"
|
||||||
|
#~ msgstr "capturé"
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"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"
|
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.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-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\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"
|
msgid "family_tree.%(extension)s"
|
||||||
msgstr "arbre_genealogique.%(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"
|
msgid "Couldn't delete picture"
|
||||||
msgstr "Echec de la suppression de la photo"
|
msgstr "Echec de la suppression de la photo"
|
||||||
|
29
poetry.lock
generated
29
poetry.lock
generated
@ -1813,6 +1813,33 @@ files = [
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.18.0"
|
version = "2.18.0"
|
||||||
@ -2626,4 +2653,4 @@ filelock = ">=3.4"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12"
|
python-versions = "^3.12"
|
||||||
content-hash = "b6202203d272cecdb607ea8ebc1ba12dd8369e4f387f65692c4a9681915e6f48"
|
content-hash = "c9c49497cc576b24c96ea914b74ef5c3a0c2981c488a599752f05aabb575f8d8"
|
||||||
|
@ -45,6 +45,10 @@ dict2xml = "^1.7.3"
|
|||||||
Sphinx = "^5" # Needed for building xapian
|
Sphinx = "^5" # Needed for building xapian
|
||||||
tomli = "^2.0.1"
|
tomli = "^2.0.1"
|
||||||
django-honeypot = "^1.2.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]
|
[tool.poetry.group.prod.dependencies]
|
||||||
# deps used in prod, but unnecessary for development
|
# deps used in prod, but unnecessary for development
|
||||||
|
@ -201,7 +201,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async delete_picture() {
|
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",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
Loading…
Reference in New Issue
Block a user