Merge pull request #773 from ae-utbm/taiste

SAS, Eboutic, Antispam, psycopg
This commit is contained in:
thomas girod 2024-08-09 13:35:26 +02:00 committed by GitHub
commit f5cee10761
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
69 changed files with 1984 additions and 1637 deletions

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
db.sqlite3 *.sqlite3
*.log *.log
*.pyc *.pyc
*.mo *.mo

0
antispam/__init__.py Normal file
View File

10
antispam/admin.py Normal file
View File

@ -0,0 +1,10 @@
from django.contrib import admin
from antispam.models import ToxicDomain
@admin.register(ToxicDomain)
class ToxicDomainAdmin(admin.ModelAdmin):
list_display = ("domain", "is_externally_managed", "created")
search_fields = ("domain", "is_externally_managed", "created")
list_filter = ("is_externally_managed",)

7
antispam/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AntispamConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
verbose_name = "antispam"
name = "antispam"

18
antispam/forms.py Normal file
View File

@ -0,0 +1,18 @@
import re
from django import forms
from django.core.validators import EmailValidator
from django.utils.translation import gettext_lazy as _
from antispam.models import ToxicDomain
class AntiSpamEmailField(forms.EmailField):
"""An email field that email addresses with a known toxic domain."""
def run_validators(self, value: str):
super().run_validators(value)
# Domain part should exist since email validation is guaranteed to run first
domain = re.search(EmailValidator.domain_regex, value)
if ToxicDomain.objects.filter(domain=domain[0]).exists():
raise forms.ValidationError(_("Email domain is not allowed."))

View File

View File

@ -0,0 +1,69 @@
import requests
from django.conf import settings
from django.core.management import BaseCommand
from django.db.models import Max
from django.utils import timezone
from antispam.models import ToxicDomain
class Command(BaseCommand):
"""Update blocked ips/mails database"""
help = "Update blocked ips/mails database"
def add_arguments(self, parser):
parser.add_argument(
"--force", action="store_true", help="Force re-creation even if up to date"
)
def _should_update(self, *, force: bool = False) -> bool:
if force:
return True
oldest = ToxicDomain.objects.filter(is_externally_managed=True).aggregate(
res=Max("created")
)["res"]
return not (oldest and timezone.now() < (oldest + timezone.timedelta(days=1)))
def _download_domains(self, providers: list[str]) -> set[str]:
domains = set()
for provider in providers:
res = requests.get(provider)
if not res.ok:
self.stderr.write(
f"Source {provider} responded with code {res.status_code}"
)
continue
domains |= set(res.content.decode().splitlines())
return domains
def _update_domains(self, domains: set[str]):
# Cleanup database
ToxicDomain.objects.filter(is_externally_managed=True).delete()
# Create database
ToxicDomain.objects.bulk_create(
[
ToxicDomain(domain=domain, is_externally_managed=True)
for domain in domains
],
ignore_conflicts=True,
)
self.stdout.write("Domain database updated")
def handle(self, *args, **options):
if not self._should_update(force=options["force"]):
self.stdout.write("Domain database is up to date")
return
self.stdout.write("Updating domain database")
domains = self._download_domains(settings.TOXIC_DOMAINS_PROVIDERS)
if not domains:
self.stderr.write(
"No domains could be fetched from settings.TOXIC_DOMAINS_PROVIDERS. "
"Please, have a look at your settings."
)
return
self._update_domains(domains)

View File

@ -0,0 +1,35 @@
# Generated by Django 4.2.14 on 2024-08-03 23:05
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="ToxicDomain",
fields=[
(
"domain",
models.URLField(
max_length=253,
primary_key=True,
serialize=False,
verbose_name="domain",
),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"is_externally_managed",
models.BooleanField(
default=False,
help_text="True if kept up-to-date using external toxic domain providers, else False",
verbose_name="is externally managed",
),
),
],
),
]

View File

19
antispam/models.py Normal file
View File

@ -0,0 +1,19 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class ToxicDomain(models.Model):
"""Domain marked as spam in public databases"""
domain = models.URLField(_("domain"), max_length=253, primary_key=True)
created = models.DateTimeField(auto_now_add=True)
is_externally_managed = models.BooleanField(
_("is externally managed"),
default=False,
help_text=_(
"True if kept up-to-date using external toxic domain providers, else False"
),
)
def __str__(self) -> str:
return self.domain

View File

@ -20,8 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import logging
import sys
from django.apps import AppConfig from django.apps import AppConfig
from django.core.cache import cache from django.core.cache import cache
@ -41,7 +40,7 @@ class SithConfig(AppConfig):
def clear_cached_memberships(**kwargs): def clear_cached_memberships(**kwargs):
Forum._club_memberships = {} Forum._club_memberships = {}
print("Connecting signals!", file=sys.stderr) logging.getLogger("django").info("Connecting signals!")
request_started.connect( request_started.connect(
clear_cached_memberships, clear_cached_memberships,
weak=False, weak=False,

View File

@ -21,19 +21,12 @@ from club.models import Club
from core.models import Group, SithFile, User from core.models import Group, SithFile, User
from core.views.site import search_user from core.views.site import search_user
from counter.models import Counter, Customer, Product from counter.models import Counter, Customer, Product
from counter.utils import is_logged_in_counter
def check_token(request):
return (
"counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter(token=request.session["counter_token"]).exists()
)
class RightManagedLookupChannel(LookupChannel): class RightManagedLookupChannel(LookupChannel):
def check_auth(self, request): def check_auth(self, request):
if not request.user.was_subscribed and not check_token(request): if not request.user.was_subscribed and not is_logged_in_counter(request):
raise PermissionDenied raise PermissionDenied

View File

@ -48,20 +48,24 @@ class Command(BaseCommand):
def handle(self, *args, force: bool, **options): def handle(self, *args, force: bool, **options):
if not os.environ.get("VIRTUAL_ENV", None): if not os.environ.get("VIRTUAL_ENV", None):
print("No virtual environment detected, this command can't be used") self.stdout.write(
"No virtual environment detected, this command can't be used"
)
return return
desired = self._desired_version() desired = self._desired_version()
if desired == self._current_version(): if desired == self._current_version():
if not force: if not force:
print( self.stdout.write(
f"Version {desired} is already installed, use --force to re-install" f"Version {desired} is already installed, use --force to re-install"
) )
return return
print(f"Version {desired} is already installed, re-installing") self.stdout.write(f"Version {desired} is already installed, re-installing")
print(f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}") self.stdout.write(
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
)
subprocess.run( subprocess.run(
[str(Path(__file__).parent / "install_xapian.sh"), desired], [str(Path(__file__).parent / "install_xapian.sh"), desired],
env=dict(os.environ), env=dict(os.environ),
).check_returncode() ).check_returncode()
print("Installation success") self.stdout.write("Installation success")

View File

@ -35,4 +35,4 @@ class Command(BaseCommand):
root_path = settings.BASE_DIR root_path = settings.BASE_DIR
with open(root_path / "core/fixtures/SYNTAX.md", "r") as md: with open(root_path / "core/fixtures/SYNTAX.md", "r") as md:
result = markdown(md.read()) result = markdown(md.read())
print(result, end="") self.stdout.write(result)

View File

@ -24,6 +24,7 @@
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import logging
import os import os
import unicodedata import unicodedata
from datetime import date, timedelta from datetime import date, timedelta
@ -981,7 +982,7 @@ class SithFile(models.Model):
return True return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True return True
return user.id == self.owner.id return user.id == self.owner_id
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user):
if hasattr(self, "profile_of"): if hasattr(self, "profile_of"):
@ -1085,19 +1086,15 @@ class SithFile(models.Model):
# file storage # file storage
parent_path = "." + self.parent.get_full_path() parent_path = "." + self.parent.get_full_path()
parent_full_path = settings.MEDIA_ROOT + parent_path parent_full_path = settings.MEDIA_ROOT + parent_path
print("Parent full path: %s" % parent_full_path)
os.makedirs(parent_full_path, exist_ok=True) os.makedirs(parent_full_path, exist_ok=True)
old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg" old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg"
new_path = "." + self.get_full_path() new_path = "." + self.get_full_path()
print("Old path: %s " % old_path)
print("New path: %s " % new_path)
try: try:
# Make this atomic, so that a FS problem rolls back the DB change # Make this atomic, so that a FS problem rolls back the DB change
with transaction.atomic(): with transaction.atomic():
# Set the new filesystem path # Set the new filesystem path
self.file.name = new_path self.file.name = new_path
self.save() self.save()
print("New file path: %s " % self.file.path)
# Really move at the FS level # Really move at the FS level
if os.path.exists(parent_full_path): if os.path.exists(parent_full_path):
os.rename( os.rename(
@ -1108,25 +1105,22 @@ class SithFile(models.Model):
# problem, and that can be solved with a simple shell # problem, and that can be solved with a simple shell
# command: `find . -type d -empty -delete` # command: `find . -type d -empty -delete`
except Exception as e: except Exception as e:
print("This file likely had a problem. Here is the exception:") logging.error(e)
print(repr(e))
print("-" * 80)
def _check_path_consistence(self): def _check_path_consistence(self):
file_path = str(self.file) file_path = str(self.file)
file_full_path = settings.MEDIA_ROOT + file_path file_full_path = settings.MEDIA_ROOT + file_path
db_path = ".%s" % self.get_full_path() db_path = ".%s" % self.get_full_path()
if not os.path.exists(file_full_path): if not os.path.exists(file_full_path):
print("%s: WARNING: real file does not exists!" % self.id) print("%s: WARNING: real file does not exists!" % self.id) # noqa T201
print("file path: %s" % file_path, end="") print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) print(" db path: %s" % db_path) # noqa T201
return False return False
if file_path != db_path: if file_path != db_path:
print("%s: " % self.id, end="") print("%s: " % self.id, end="") # noqa T201
print("file path: %s" % file_path, end="") print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) print(" db path: %s" % db_path) # noqa T201
return False return False
print("%s OK (%s)" % (self.id, file_path))
return True return True
def _check_fs(self): def _check_fs(self):
@ -1137,11 +1131,9 @@ class SithFile(models.Model):
else: else:
self._check_path_consistence() self._check_path_consistence()
def __getattribute__(self, attr): @property
if attr == "is_file": def is_file(self):
return not self.is_folder return not self.is_folder
else:
return super().__getattribute__(attr)
@cached_property @cached_property
def as_picture(self): def as_picture(self):

File diff suppressed because one or more lines are too long

View File

@ -65,3 +65,21 @@ function display_notif() {
function getCSRFToken() { function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val(); return $("[name=csrfmiddlewaretoken]").val();
} }
const initialUrlParams = new URLSearchParams(window.location.search);
function update_query_string(key, value) {
const url = new URL(window.location.href);
if (!value) {
// If the value is null, undefined or empty => delete it
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
url.searchParams.delete(key)
value.forEach((v) => url.searchParams.append(key, v))
} else {
url.searchParams.set(key, value);
}
history.pushState(null, document.title, url.toString());
}

View File

@ -3,6 +3,7 @@
.pagination { .pagination {
text-align: center; text-align: center;
gap: 10px; gap: 10px;
margin: 30px;
button { button {
background-color: $secondary-neutral-light-color; background-color: $secondary-neutral-light-color;

View File

@ -93,6 +93,32 @@ a:not(.button) {
} }
} }
[aria-busy] {
--loading-size: 50px;
--loading-stroke: 5px;
--loading-duration: 1s;
position: relative;
}
[aria-busy]:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: var(--loading-size);
height: var(--loading-size);
margin-top: calc(var(--loading-size) / 2 * -1);
margin-left: calc(var(--loading-size) / 2 * -1);
border: var(--loading-stroke) solid rgba(0, 0, 0, .15);
border-radius: 50%;
border-top-color: rgba(0, 0, 0, 0.5);
animation: rotate calc(var(--loading-duration)) linear infinite;
}
@keyframes rotate {
100% { transform: rotate(360deg); }
}
.ib { .ib {
display: inline-block; display: inline-block;
padding: 1px; padding: 1px;

View File

@ -102,20 +102,10 @@ main {
border-radius: 10px; border-radius: 10px;
} }
.paginator {
display: flex;
justify-content: center;
gap: 10px;
width: -moz-fit-content;
width: fit-content;
background-color: rgba(0,0,0,.1);
border-radius: 10px;
padding: 10px;
margin: 10px 0 10px auto;
}
.photos, .photos,
.albums { .albums {
margin: 20px;
min-height: 50px; // To contain the aria-busy loading wheel, even if empty
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -161,17 +151,13 @@ main {
> .album { > .album {
box-sizing: border-box; box-sizing: border-box;
background-color: #333333; background-color: #333333;
background-size: cover; background-size: contain;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center center; background-position: center center;
width: calc(16 / 9 * 128px); width: calc(16 / 9 * 128px);
height: 128px; height: 128px;
&.vertical {
background-size: contain;
}
margin: 0; margin: 0;
padding: 0; padding: 0;
box-shadow: none; box-shadow: none;

View File

@ -27,6 +27,9 @@
{% block additional_css %}{% endblock %} {% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %} {% block additional_js %}{% endblock %}
{# Alpine JS must be loaded after scripts that use it. #}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %} {% endblock %}
</head> </head>

View File

@ -9,10 +9,6 @@
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s profile{% endtrans %} {% trans user_name=profile.get_display_name() %}{{ user_name }}'s profile{% endtrans %}
{% endblock %} {% endblock %}
{% block additional_js %}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block content %} {% block content %}
<div class="user_profile_page" x-data> <div class="user_profile_page" x-data>
<div class="user_profile"> <div class="user_profile">

View File

@ -10,7 +10,6 @@
window.showSaveFilePicker = showSaveFilePicker; /* Export function to normal javascript */ window.showSaveFilePicker = showSaveFilePicker; /* Export function to normal javascript */
</script> </script>
<script defer type="text/javascript" src="{{ static('core/js/zipjs/zip-fs-full.min.js') }}"></script> <script defer type="text/javascript" src="{{ static('core/js/zipjs/zip-fs-full.min.js') }}"></script>
<script defer src="{{ static("core/js/alpinejs.min.js") }}"></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -24,7 +23,7 @@
<button <button
:disabled="in_progress" :disabled="in_progress"
class="btn btn-blue" class="btn btn-blue"
@click="download('{{ url("api:pictures") }}?users_identified={{ object.id }}')" @click="download_zip()"
> >
<i class="fa fa-download"></i> <i class="fa fa-download"></i>
{% trans %}Download all my pictures{% endtrans %} {% trans %}Download all my pictures{% endtrans %}
@ -87,13 +86,34 @@
Alpine.data("picture_download", () => ({ Alpine.data("picture_download", () => ({
in_progress: false, in_progress: false,
async download(url) { /**
* @return {Promise<Picture[]>}
*/
async get_pictures() {
{# The API forbids to get more than 199 items at once
from paginated routes.
In order to download all the user pictures, it may be needed
to performs multiple requests #}
const max_per_page = 1;
const url = "{{ url("api:pictures") }}"
+ "?users_identified={{ object.id }}"
+ `&page_size=${max_per_page}`;
let promises = [];
const nb_pages = Math.ceil({{ nb_pictures }} / max_per_page);
for (let i = 1; i <= nb_pages; i++) {
promises.push(
fetch(url + `&page=${i}`).then(res => res.json().then(json => json.results))
);
}
return (await Promise.all(promises)).flat()
},
async download_zip(){
this.in_progress = true; this.in_progress = true;
const bar = this.$refs.progress; const bar = this.$refs.progress;
bar.value = 0; bar.value = 0;
const pictures = await this.get_pictures();
/** @type Picture[] */
const pictures = await (await fetch(url)).json();
bar.max = pictures.length; bar.max = pictures.length;
const fileHandle = await window.showSaveFilePicker({ const fileHandle = await window.showSaveFilePicker({

View File

@ -24,8 +24,10 @@ from django.core.mail import EmailMessage
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Membership from club.models import Membership
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User from core.models import AnonymousUser, Group, Page, User
@ -48,6 +50,10 @@ class TestUserRegistration:
"captcha_1": "PASSED", "captcha_1": "PASSED",
} }
@pytest.fixture()
def scam_domains(self):
return [baker.make(ToxicDomain, domain="scammer.spam")]
def test_register_user_form_ok(self, client, valid_payload): def test_register_user_form_ok(self, client, valid_payload):
"""Should register a user correctly.""" """Should register a user correctly."""
assert not User.objects.filter(email=valid_payload["email"]).exists() assert not User.objects.filter(email=valid_payload["email"]).exists()
@ -64,14 +70,25 @@ class TestUserRegistration:
{"password2": "not the same as password1"}, {"password2": "not the same as password1"},
"Les deux mots de passe ne correspondent pas.", "Les deux mots de passe ne correspondent pas.",
), ),
({"email": "not-an-email"}, "Saisissez une adresse de courriel valide."), (
{"email": "not-an-email"},
"Saisissez une adresse de courriel valide.",
),
(
{"email": "not\\an@email.com"},
"Saisissez une adresse de courriel valide.",
),
(
{"email": "legit@scammer.spam"},
"Le domaine de l'addresse e-mail n'est pas autorisé.",
),
({"first_name": ""}, "Ce champ est obligatoire."), ({"first_name": ""}, "Ce champ est obligatoire."),
({"last_name": ""}, "Ce champ est obligatoire."), ({"last_name": ""}, "Ce champ est obligatoire."),
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
], ],
) )
def test_register_user_form_fail( def test_register_user_form_fail(
self, client, valid_payload, payload_edit, expected_error self, client, scam_domains, valid_payload, payload_edit, expected_error
): ):
"""Should not register a user correctly.""" """Should not register a user correctly."""
payload = valid_payload | payload_edit payload = valid_payload | payload_edit

View File

@ -96,10 +96,7 @@ def get_semester_code(d: Optional[date] = None) -> str:
def scale_dimension(width, height, long_edge): def scale_dimension(width, height, long_edge):
if width > height: ratio = long_edge / max(width, height)
ratio = long_edge * 1.0 / width
else:
ratio = long_edge * 1.0 / height
return int(width * ratio), int(height * ratio) return int(width * ratio), int(height * ratio)
@ -107,8 +104,8 @@ def resize_image(im, edge, img_format):
(w, h) = im.size (w, h) = im.size
(width, height) = scale_dimension(w, h, long_edge=edge) (width, height) = scale_dimension(w, h, long_edge=edge)
content = BytesIO() content = BytesIO()
# use the lanczos filter for antialiasing # use the lanczos filter for antialiasing and discard the alpha channel
im = im.resize((width, height), Resampling.LANCZOS) im = im.resize((width, height), Resampling.LANCZOS).convert("RGB")
try: try:
im.save( im.save(
fp=content, fp=content,

View File

@ -12,6 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
@ -21,7 +22,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import Http404, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.http import http_date from django.utils.http import http_date
@ -37,33 +38,42 @@ from core.views import (
CanViewMixin, CanViewMixin,
can_view, can_view,
) )
from counter.models import Counter from counter.utils import is_logged_in_counter
def send_file(request, file_id, file_class=SithFile, file_attr="file"): def send_file(
"""Send a file through Django without loading the whole file into request: HttpRequest,
memory at once. The FileWrapper will turn the file object into an file_id: int,
iterator for chunks of 8KB. file_class: type[SithFile] = SithFile,
file_attr: str = "file",
) -> HttpResponse:
"""Send a protected file, if the user can see it.
In prod, the server won't handle the download itself,
but set the appropriate headers in the response to make the reverse-proxy
deal with it.
In debug mode, the server will directly send the file.
""" """
f = get_object_or_404(file_class, id=file_id) f = get_object_or_404(file_class, id=file_id)
if not ( if not can_view(f, request.user) and not is_logged_in_counter(request):
can_view(f, request.user)
or (
"counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
)
):
raise PermissionDenied raise PermissionDenied
name = f.__getattribute__(file_attr).name name = getattr(f, file_attr).name
filepath = settings.MEDIA_ROOT / name filepath = settings.MEDIA_ROOT / name
# check if file exists on disk # check if file exists on disk
if not filepath.exists(): if not filepath.exists():
raise Http404 raise Http404
if not settings.DEBUG:
# When receiving a response with the Accel-Redirect header,
# the reverse proxy will automatically handle the file sending.
# This is really hard to test (thus isn't tested)
# so please do not mess with this.
response = HttpResponse(status=200)
response["Content-Type"] = ""
response["X-Accel-Redirect"] = quote(urljoin(settings.MEDIA_URL, name))
return response
with open(filepath, "rb") as filename: with open(filepath, "rb") as filename:
wrapper = FileWrapper(filename) wrapper = FileWrapper(filename)
response = HttpResponse(wrapper, content_type=f.mime_type) response = HttpResponse(wrapper, content_type=f.mime_type)

View File

@ -16,7 +16,7 @@
# details. # details.
# #
# You should have received a copy of the GNU General Public License along with # You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple # this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
@ -45,6 +45,7 @@ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
from antispam.forms import AntiSpamEmailField
from core.models import Gift, Page, SithFile, User from core.models import Gift, Page, SithFile, User
from core.utils import resize_image from core.utils import resize_image
@ -194,6 +195,9 @@ class RegisteringForm(UserCreationForm):
class Meta: class Meta:
model = User model = User
fields = ("first_name", "last_name", "email") fields = ("first_name", "last_name", "email")
field_classes = {
"email": AntiSpamEmailField,
}
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):

View File

@ -22,6 +22,7 @@
# #
# #
import itertools import itertools
import logging
# This file contains all the views that concern the user model # This file contains all the views that concern the user model
from datetime import date, timedelta from datetime import date, timedelta
@ -318,6 +319,7 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
.order_by("-parent__date", "-date") .order_by("-parent__date", "-date")
.annotate(album=F("parent__name")) .annotate(album=F("parent__name"))
) )
kwargs["nb_pictures"] = len(pictures)
kwargs["albums"] = { kwargs["albums"] = {
album: list(picts) album: list(picts)
for album, picts in itertools.groupby(pictures, lambda i: i.album) for album, picts in itertools.groupby(pictures, lambda i: i.album)
@ -801,7 +803,7 @@ class UserAccountView(UserAccountBase):
product__eticket=None product__eticket=None
).all() ).all()
except Exception as e: except Exception as e:
print(repr(e)) logging.error(e)
return kwargs return kwargs

View File

@ -310,11 +310,26 @@ class Product(models.Model):
Returns: Returns:
True if the user can buy this product else False True if the user can buy this product else False
Warnings:
This performs a db query, thus you can quickly have
a N+1 queries problem if you call it in a loop.
Hopefully, you can avoid that if you prefetch the buying_groups :
```python
user = User.objects.get(username="foobar")
products = [
p
for p in Product.objects.prefetch_related("buying_groups")
if p.can_be_sold_to(user)
]
```
""" """
if not self.buying_groups.exists(): buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True return True
for group_id in self.buying_groups.values_list("pk", flat=True): for group in buying_groups:
if user.is_in_group(pk=group_id): if user.is_in_group(pk=group.id):
return True return True
return False return False
@ -690,14 +705,14 @@ class Selling(models.Model):
self.customer.amount -= self.quantity * self.unit_price self.customer.amount -= self.quantity * self.unit_price
self.customer.save(allow_negative=allow_negative, is_selling=True) self.customer.save(allow_negative=allow_negative, is_selling=True)
self.is_validated = True self.is_validated = True
u = User.objects.filter(id=self.customer.user.id).first() user = self.customer.user
if u.was_subscribed: if user.was_subscribed:
if ( if (
self.product self.product
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER
): ):
sub = Subscription( sub = Subscription(
member=u, member=user,
subscription_type="un-semestre", subscription_type="un-semestre",
payment_method="EBOUTIC", payment_method="EBOUTIC",
location="EBOUTIC", location="EBOUTIC",
@ -719,9 +734,8 @@ class Selling(models.Model):
self.product self.product
and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS and self.product.id == settings.SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS
): ):
u = User.objects.filter(id=self.customer.user.id).first()
sub = Subscription( sub = Subscription(
member=u, member=user,
subscription_type="deux-semestres", subscription_type="deux-semestres",
payment_method="EBOUTIC", payment_method="EBOUTIC",
location="EBOUTIC", location="EBOUTIC",
@ -739,13 +753,13 @@ class Selling(models.Model):
start=sub.subscription_start, start=sub.subscription_start,
) )
sub.save() sub.save()
if self.customer.user.preferences.notify_on_click: if user.preferences.notify_on_click:
Notification( Notification(
user=self.customer.user, user=user,
url=reverse( url=reverse(
"core:user_account_detail", "core:user_account_detail",
kwargs={ kwargs={
"user_id": self.customer.user.id, "user_id": user.id,
"year": self.date.year, "year": self.date.year,
"month": self.date.month, "month": self.date.month,
}, },
@ -754,19 +768,15 @@ class Selling(models.Model):
type="SELLING", type="SELLING",
).save() ).save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
try: if hasattr(self.product, "eticket"):
# The product has no id until it's saved self.send_mail_customer()
if self.product.eticket:
self.send_mail_customer()
except:
pass
def is_owned_by(self, user): def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
return False return False
return user.is_owner(self.counter) and self.payment_method != "CARD" return self.payment_method != "CARD" and user.is_owner(self.counter)
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user: User) -> bool:
if ( if (
not hasattr(self, "customer") or self.customer is None not hasattr(self, "customer") or self.customer is None
): # Customer can be set to Null ): # Customer can be set to Null
@ -812,7 +822,9 @@ class Selling(models.Model):
"url": self.customer.get_full_url(), "url": self.customer.get_full_url(),
"eticket": self.get_eticket_full_url(), "eticket": self.get_eticket_full_url(),
} }
self.customer.user.email_user(subject, message_txt, html_message=message_html) self.customer.user.email_user(
subject, message_txt, html_message=message_html, fail_silently=True
)
def get_eticket_full_url(self): def get_eticket_full_url(self):
eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id}) eticket_url = reverse("counter:eticket_pdf", kwargs={"selling_id": self.id})

View File

@ -7,7 +7,6 @@
{% block additional_js %} {% block additional_js %}
<script src="{{ static('counter/js/counter_click.js') }}" defer></script> <script src="{{ static('counter/js/counter_click.js') }}" defer></script>
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %} {% endblock %}
{% block info_boxes %} {% block info_boxes %}

View File

@ -16,8 +16,9 @@ import json
import re import re
import string import string
import pytest
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import timedelta from django.utils.timezone import timedelta
@ -303,18 +304,11 @@ class TestCounterStats(TestCase):
] ]
class TestBillingInfo(TestCase): @pytest.mark.django_db
@classmethod class TestBillingInfo:
def setUpTestData(cls): @pytest.fixture
cls.payload_1 = { def payload(self):
"first_name": "Subscribed", return {
"last_name": "User",
"address_1": "1 rue des Huns",
"zip_code": "90000",
"city": "Belfort",
"country": "FR",
}
cls.payload_2 = {
"first_name": "Subscribed", "first_name": "Subscribed",
"last_name": "User", "last_name": "User",
"address_1": "3, rue de Troyes", "address_1": "3, rue de Troyes",
@ -322,213 +316,80 @@ class TestBillingInfo(TestCase):
"city": "Sète", "city": "Sète",
"country": "FR", "country": "FR",
} }
cls.root = User.objects.get(username="root")
cls.subscriber = User.objects.get(username="subscriber")
def test_edit_infos(self): def test_edit_infos(self, client: Client, payload: dict):
user = self.subscriber user = subscriber_user.make()
BillingInfo.objects.get_or_create( baker.make(BillingInfo, customer=user.customer)
customer=user.customer, defaults=self.payload_1 client.force_login(user)
) response = client.put(
self.client.force_login(user) reverse("api:put_billing_info", args=[user.id]),
response = self.client.post( json.dumps(payload),
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json", content_type="application/json",
) )
user = User.objects.get(username="subscriber") user.refresh_from_db()
infos = BillingInfo.objects.get(customer__user=user) infos = BillingInfo.objects.get(customer__user=user)
assert response.status_code == 200 assert response.status_code == 200
self.assertJSONEqual(response.content, {"errors": None})
assert hasattr(user.customer, "billing_infos") assert hasattr(user.customer, "billing_infos")
assert infos.customer == user.customer assert infos.customer == user.customer
assert infos.first_name == "Subscribed" for key, val in payload.items():
assert infos.last_name == "User" assert getattr(infos, key) == val
assert infos.address_1 == "3, rue de Troyes"
assert infos.address_2 is None
assert infos.zip_code == "34301"
assert infos.city == "Sète"
assert infos.country == "FR"
def test_create_infos_for_user_with_account(self): @pytest.mark.parametrize(
user = User.objects.get(username="subscriber") "user_maker", [subscriber_user.make, lambda: baker.make(User)]
if hasattr(user.customer, "billing_infos"): )
user.customer.billing_infos.delete() @pytest.mark.django_db
self.client.force_login(user) def test_create_infos(self, client: Client, user_maker, payload):
response = self.client.post( user = user_maker()
reverse("counter:create_billing_info", args=[user.id]), client.force_login(user)
json.dumps(self.payload_1), assert not BillingInfo.objects.filter(customer__user=user).exists()
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json", content_type="application/json",
) )
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
assert response.status_code == 200 assert response.status_code == 200
self.assertJSONEqual(response.content, {"errors": None}) user.refresh_from_db()
assert hasattr(user.customer, "billing_infos")
assert infos.customer == user.customer
assert infos.first_name == "Subscribed"
assert infos.last_name == "User"
assert infos.address_1 == "1 rue des Huns"
assert infos.address_2 is None
assert infos.zip_code == "90000"
assert infos.city == "Belfort"
assert infos.country == "FR"
def test_create_infos_for_user_without_account(self):
user = User.objects.get(username="subscriber")
if hasattr(user, "customer"):
user.customer.delete()
self.client.force_login(user)
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(self.payload_1),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
assert hasattr(user, "customer") assert hasattr(user, "customer")
assert hasattr(user.customer, "billing_infos")
assert response.status_code == 200
self.assertJSONEqual(response.content, {"errors": None})
infos = BillingInfo.objects.get(customer__user=user) infos = BillingInfo.objects.get(customer__user=user)
self.assertEqual(user.customer, infos.customer)
assert infos.first_name == "Subscribed"
assert infos.last_name == "User"
assert infos.address_1 == "1 rue des Huns"
assert infos.address_2 is None
assert infos.zip_code == "90000"
assert infos.city == "Belfort"
assert infos.country == "FR"
def test_create_invalid(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.force_login(user)
# address_1, zip_code and country are missing
payload = {
"first_name": user.first_name,
"last_name": user.last_name,
"city": "Belfort",
}
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
self.assertEqual(400, response.status_code)
assert not hasattr(user.customer, "billing_infos")
expected_errors = {
"errors": [
{"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]},
{"field": "Code postal", "messages": ["Ce champ est obligatoire."]},
{"field": "Country", "messages": ["Ce champ est obligatoire."]},
]
}
self.assertJSONEqual(response.content, expected_errors)
def test_edit_invalid(self):
user = User.objects.get(username="subscriber")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
self.client.force_login(user)
# address_1, zip_code and country are missing
payload = {
"first_name": user.first_name,
"last_name": user.last_name,
"city": "Belfort",
}
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
user = User.objects.get(username="subscriber")
self.assertEqual(400, response.status_code)
assert hasattr(user.customer, "billing_infos")
expected_errors = {
"errors": [
{"field": "Adresse 1", "messages": ["Ce champ est obligatoire."]},
{"field": "Code postal", "messages": ["Ce champ est obligatoire."]},
{"field": "Country", "messages": ["Ce champ est obligatoire."]},
]
}
self.assertJSONEqual(response.content, expected_errors)
def test_edit_other_user(self):
user = User.objects.get(username="sli")
self.client.login(username="subscriber", password="plop")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
self.assertEqual(403, response.status_code)
def test_edit_not_existing_infos(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.force_login(user)
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
self.assertEqual(404, response.status_code)
def test_edit_by_root(self):
user = User.objects.get(username="subscriber")
BillingInfo.objects.get_or_create(
customer=user.customer, defaults=self.payload_1
)
self.client.force_login(self.root)
response = self.client.post(
reverse("counter:edit_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
assert response.status_code == 200
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
self.assertJSONEqual(response.content, {"errors": None})
assert hasattr(user.customer, "billing_infos")
self.assertEqual(user.customer, infos.customer)
self.assertEqual("Subscribed", infos.first_name)
self.assertEqual("User", infos.last_name)
self.assertEqual("3, rue de Troyes", infos.address_1)
self.assertEqual(None, infos.address_2)
self.assertEqual("34301", infos.zip_code)
self.assertEqual("Sète", infos.city)
self.assertEqual("FR", infos.country)
def test_create_by_root(self):
user = User.objects.get(username="subscriber")
if hasattr(user.customer, "billing_infos"):
user.customer.billing_infos.delete()
self.client.force_login(self.root)
response = self.client.post(
reverse("counter:create_billing_info", args=[user.id]),
json.dumps(self.payload_2),
content_type="application/json",
)
assert response.status_code == 200
user = User.objects.get(username="subscriber")
infos = BillingInfo.objects.get(customer__user=user)
self.assertJSONEqual(response.content, {"errors": None})
assert hasattr(user.customer, "billing_infos") assert hasattr(user.customer, "billing_infos")
assert infos.customer == user.customer assert infos.customer == user.customer
assert infos.first_name == "Subscribed" for key, val in payload.items():
assert infos.last_name == "User" assert getattr(infos, key) == val
assert infos.address_1 == "3, rue de Troyes"
assert infos.address_2 is None def test_invalid_data(self, client: Client, payload):
assert infos.zip_code == "34301" user = subscriber_user.make()
assert infos.city == "Sète" client.force_login(user)
assert infos.country == "FR" # address_1, zip_code and country are missing
del payload["city"]
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 422
user.customer.refresh_from_db()
assert not hasattr(user.customer, "billing_infos")
@pytest.mark.parametrize(
("operator_maker", "expected_code"),
[
(subscriber_user.make, 403),
(lambda: baker.make(User), 403),
(lambda: baker.make(User, is_superuser=True), 200),
],
)
def test_edit_other_user(
self, client: Client, operator_maker, expected_code: int, payload: dict
):
user = subscriber_user.make()
client.force_login(operator_maker())
baker.make(BillingInfo, customer=user.customer)
response = client.put(
reverse("api:put_billing_info", args=[user.id]),
json.dumps(payload),
content_type="application/json",
)
assert response.status_code == expected_code
class TestBarmanConnection(TestCase): class TestBarmanConnection(TestCase):

View File

@ -57,16 +57,6 @@ urlpatterns = [
StudentCardDeleteView.as_view(), StudentCardDeleteView.as_view(),
name="delete_student_card", name="delete_student_card",
), ),
path(
"customer/<int:user_id>/billing_info/create",
create_billing_info,
name="create_billing_info",
),
path(
"customer/<int:user_id>/billing_info/edit",
edit_billing_info,
name="edit_billing_info",
),
path("admin/<int:counter_id>/", CounterEditView.as_view(), name="admin"), path("admin/<int:counter_id>/", CounterEditView.as_view(), name="admin"),
path( path(
"admin/<int:counter_id>/prop/", "admin/<int:counter_id>/prop/",

35
counter/utils.py Normal file
View File

@ -0,0 +1,35 @@
from urllib.parse import urlparse
from django.http import HttpRequest
from django.urls import resolve
from counter.models import Counter
def is_logged_in_counter(request: HttpRequest) -> bool:
"""Check if the request is sent from a device logged to a counter.
The request must also be sent within the frame of a counter's activity.
Trying to use this function to manage access to non-sas
related resources probably won't work.
A request is considered as coming from a logged counter if :
- Its referer comes from the counter app
(eg. fetching user pictures from the click UI)
or the request path belongs to the counter app
(eg. the barman went back to the main by missclick and go back
to the counter)
- The current session has a counter token associated with it.
- A counter with this token exists.
"""
referer_ok = (
"HTTP_REFERER" in request.META
and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
)
return (
(referer_ok or request.resolver_match.app_name == "counter")
and "counter_token" in request.session
and request.session["counter_token"]
and Counter.objects.filter(token=request.session["counter_token"]).exists()
)

View File

@ -12,7 +12,6 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
import json
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
@ -21,7 +20,6 @@ from urllib.parse import parse_qs
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import DataError, transaction from django.db import DataError, transaction
from django.db.models import F from django.db.models import F
@ -56,7 +54,6 @@ from core.utils import get_semester_code, get_start_of_semester
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
from core.views.forms import LoginForm from core.views.forms import LoginForm
from counter.forms import ( from counter.forms import (
BillingInfoForm,
CashSummaryFormBase, CashSummaryFormBase,
CounterEditForm, CounterEditForm,
EticketForm, EticketForm,
@ -67,7 +64,6 @@ from counter.forms import (
StudentCardForm, StudentCardForm,
) )
from counter.models import ( from counter.models import (
BillingInfo,
CashRegisterSummary, CashRegisterSummary,
CashRegisterSummaryItem, CashRegisterSummaryItem,
Counter, Counter,
@ -80,6 +76,7 @@ from counter.models import (
Selling, Selling,
StudentCard, StudentCard,
) )
from counter.utils import is_logged_in_counter
class CounterAdminMixin(View): class CounterAdminMixin(View):
@ -901,15 +898,9 @@ class RefillingDeleteView(DeleteView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin.""" """We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object() self.object = self.get_object()
if ( if timezone.now() - self.object.date <= timedelta(
timezone.now() - self.object.date minutes=settings.SITH_LAST_OPERATIONS_LIMIT
<= timedelta(minutes=settings.SITH_LAST_OPERATIONS_LIMIT) ) and is_logged_in_counter(request):
and "counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
):
self.success_url = reverse( self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id} "counter:details", kwargs={"counter_id": self.object.counter.id}
) )
@ -932,15 +923,9 @@ class SellingDeleteView(DeleteView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin.""" """We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object() self.object = self.get_object()
if ( if timezone.now() - self.object.date <= timedelta(
timezone.now() - self.object.date minutes=settings.SITH_LAST_OPERATIONS_LIMIT
<= timedelta(minutes=settings.SITH_LAST_OPERATIONS_LIMIT) ) and is_logged_in_counter(request):
and "counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
):
self.success_url = reverse( self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id} "counter:details", kwargs={"counter_id": self.object.counter.id}
) )
@ -1175,14 +1160,7 @@ class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""We have here again a very particular right handling.""" """We have here again a very particular right handling."""
self.object = self.get_object() self.object = self.get_object()
if ( if is_logged_in_counter(request) and self.object.barmen_list:
self.object.barmen_list
and "counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
return HttpResponseRedirect( return HttpResponseRedirect(
reverse("counter:details", kwargs={"counter_id": self.object.id}) reverse("counter:details", kwargs={"counter_id": self.object.id})
@ -1215,14 +1193,7 @@ class CounterCashSummaryView(CounterTabsMixin, CanViewMixin, DetailView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""We have here again a very particular right handling.""" """We have here again a very particular right handling."""
self.object = self.get_object() self.object = self.get_object()
if ( if is_logged_in_counter(request) and self.object.barmen_list:
self.object.barmen_list
and "counter_token" in request.session.keys()
and request.session["counter_token"]
and Counter.objects.filter( # check if not null for counters that have no token set
token=request.session["counter_token"]
).exists()
):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
return HttpResponseRedirect( return HttpResponseRedirect(
reverse("counter:details", kwargs={"counter_id": self.object.id}) reverse("counter:details", kwargs={"counter_id": self.object.id})
@ -1594,51 +1565,3 @@ class StudentCardFormView(FormView):
return reverse_lazy( return reverse_lazy(
"core:user_prefs", kwargs={"user_id": self.customer.user.pk} "core:user_prefs", kwargs={"user_id": self.customer.user.pk}
) )
def __manage_billing_info_req(request, user_id, *, delete_if_fail=False):
data = json.loads(request.body)
form = BillingInfoForm(data)
if not form.is_valid():
if delete_if_fail:
Customer.objects.get(user__id=user_id).billing_infos.delete()
errors = [
{"field": str(form.fields[k].label), "messages": v}
for k, v in form.errors.items()
]
content = json.dumps({"errors": errors})
return HttpResponse(status=400, content=content)
if form.is_valid():
infos = Customer.objects.get(user__id=user_id).billing_infos
for field in form.fields:
infos.__dict__[field] = form[field].value()
infos.save()
content = json.dumps({"errors": None})
return HttpResponse(status=200, content=content)
@login_required
@require_POST
def create_billing_info(request, user_id):
user = request.user
if user.id != user_id and not user.has_perm("counter:add_billinginfo"):
raise PermissionDenied()
user = get_object_or_404(User, pk=user_id)
customer, _ = Customer.get_or_create(user)
BillingInfo.objects.create(customer=customer)
return __manage_billing_info_req(request, user_id, delete_if_fail=True)
@login_required
@require_POST
def edit_billing_info(request, user_id):
user = request.user
if user.id != user_id and not user.has_perm("counter:change_billinginfo"):
raise PermissionDenied()
user = get_object_or_404(User, pk=user_id)
if not hasattr(user, "customer"):
raise Http404
if not hasattr(user.customer, "billing_infos"):
raise Http404
return __manage_billing_info_req(request, user_id)

View File

@ -0,0 +1 @@
::: antispam.forms

View File

@ -0,0 +1 @@
::: antispam.models

View File

@ -0,0 +1,275 @@
Si le projet marche chez vous après avoir suivi les étapes
données dans la page précédente, alors vous pouvez développer.
Ce que nous nous vous avons présenté n'est absolument pas
la même configuration que celle du site, mais elle n'en
est pas moins fonctionnelle.
Cependant, vous pourriez avoir envie de faire en sorte
que votre environnement de développement soit encore plus
proche de celui en production.
Voici les étapes à suivre pour ça.
!!!tip
Configurer les dépendances du projet
peut demander beaucoup d'allers et retours entre
votre répertoire projet et divers autres emplacements.
Vous pouvez gagner du temps en déclarant un alias :
=== "bash/zsh"
```bash
alias cdp="cd /repertoire/du/projet"
```
=== "nu"
```nu
alias cdp = cd /repertoire/du/projet
```
Chaque fois qu'on vous demandera de retourner au répertoire
projet, vous aurez juste à faire :
```bash
cdp
```
## Installer les dépendances manquantes
Pour installer complètement le projet, il va falloir
quelques dépendances en plus.
Commencez par installer les dépendances système :
=== "Linux"
=== "Debian/Ubuntu"
```bash
sudo apt install postgresql redis libq-dev nginx
```
=== "Arch Linux"
```bash
sudo pacman -S postgresql redis nginx
```
=== "macOS"
```bash
brew install postgresql redis lipbq nginx
export PATH="/usr/local/opt/libpq/bin:$PATH"
source ~/.zshrc
```
Puis, installez les dépendances poetry nécessaires en prod :
```bash
poetry install --with prod
```
!!! info
Certaines dépendances peuvent être un peu longues à installer
(notamment psycopg-c).
C'est parce que ces dépendances compilent certains modules
à l'installation.
## Configurer Redis
Redis est utilisé comme cache.
Assurez-vous qu'il tourne :
```bash
sudo systemctl redis status
```
Et s'il ne tourne pas, démarrez-le :
```bash
sudo systemctl start redis
sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot
```
Puis ajoutez le code suivant à la fin de votre fichier
`settings_custom.py` :
```python
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379",
}
}
```
## Configurer PostgreSQL
PostgreSQL est utilisé comme base de données.
Passez sur le compte de l'utilisateur postgres
et lancez l'invite de commande sql :
```bash
sudo su - postgres
psql
```
Puis configurez la base de données :
```postgresql
CREATE DATABASE sith;
CREATE USER sith WITH PASSWORD 'password';
ALTER ROLE sith SET client_encoding TO 'utf8';
ALTER ROLE sith SET default_transaction_isolation TO 'read committed';
ALTER ROLE sith SET timezone TO 'UTC';
GRANT ALL PRIVILEGES ON DATABASE sith TO SITH;
\q
```
Si vous utilisez une version de PostgreSQL supérieure ou égale
à 15, vous devez exécuter une commande en plus,
en étant connecté en tant que postgres :
```bash
psql -d sith -c "GRANT ALL PRIVILEGES ON SCHEMA public to sith";
```
Puis ajoutez le code suivant à la fin de votre
`settings_custom.py` :
```python
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "sith",
"USER": "sith",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "", # laissez ce champ vide pour que le choix du port soit automatique
}
}
```
Enfin, créez vos données :
```bash
poetry run ./manage.py populate
```
!!! note
N'oubliez de quitter la session de l'utilisateur
postgres après avoir configuré la db.
## Configurer nginx
Nginx est utilisé comme reverse-proxy.
!!!warning
Nginx ne sert pas les fichiers de la même manière que Django.
Les fichiers statiques servis seront ceux du dossier `/static`,
tels que générés par les commandes `collectstatic` et
`compilestatic`.
Si vous changez du css ou du js sans faire tourner
ces commandes, ces changements ne seront pas reflétés.
De manière générale, utiliser nginx en dev n'est pas très utile,
voire est gênant si vous travaillez sur le front.
Ne vous embêtez pas avec ça, sauf par curiosité intellectuelle,
ou bien si vous voulez tester spécifiquement
des interactions avec le reverse proxy.
Placez-vous dans le répertoire `/etc/nginx`,
et créez les dossiers et fichiers nécessaires :
```bash
cd /etc/nginx/
sudo mkdir sites-enabled sites-available
sudo touch sites-available/sith.conf
sudo ln -s /etc/nginx/sites-available/sith.conf sites-enabled/sith.conf
```
Puis ouvrez le fichier `sites-available/sith.conf` et mettez-y le contenu suivant :
```nginx
server {
listen 8000;
server_name _;
location /static/;
root /repertoire/du/projet;
}
location ~ ^/data/(products|com|club_logos)/ {
root /repertoire/du/projet;
}
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {
# https://nginx.org/en/docs/http/ngx_http_core_module.html#internal
internal;
root /repertoire/du/projet;
}
location / {
proxy_pass http://127.0.0.1:8001;
include uwsgi_params;
}
}
```
Ouvrez le fichier `nginx.conf`, et ajoutez la configuration suivante :
```nginx
http {
# Toute la configuration
# éventuellement déjà là
include /etc/nginx/sites-enabled/sith.conf;
}
```
Vérifiez que votre configuration est bonne :
```bash
sudo nginx -t
```
Si votre configuration n'est pas bonne, corrigez-la.
Puis lancez ou relancez nginx :
```bash
sudo systemctl restart nginx
```
Dans votre `settings_custom.py`, remplacez `DEBUG=True` par `DEBUG=False`.
Enfin, démarrez le serveur Django :
```bash
cd /repertoire/du/projet
poetry run ./manage.py runserver 8001
```
Et c'est bon, votre reverse-proxy est prêt à tourner devant votre serveur.
Nginx écoutera sur le port 8000.
Toutes les requêtes vers des fichiers statiques et les medias publiques
seront seront servies directement par nginx.
Toutes les autres requêtes seront transmises au serveur django.
## Mettre à jour la base de données antispam
L'anti spam nécessite d'être à jour par rapport à des bases de données externes.
Il existe une commande pour ça qu'il faut lancer régulièrement.
Lors de la mise en production, il est judicieux de configurer
un cron pour la mettre à jour au moins une fois par jour.
```bash
python manage.py update_spam_database
```

View File

@ -64,17 +64,19 @@ sith3/
│ └── ... │ └── ...
├── trombi/ (22) ├── trombi/ (22)
│ └── ... │ └── ...
├── antispam/ (23)
│ └── ...
├── .coveragerc (23) ├── .coveragerc (24)
├── .envrc (24) ├── .envrc (25)
├── .gitattributes ├── .gitattributes
├── .gitignore ├── .gitignore
├── .mailmap ├── .mailmap
├── .env.exemple ├── .env.exemple
├── manage.py (25) ├── manage.py (26)
├── mkdocs.yml (26) ├── mkdocs.yml (27)
├── poetry.lock ├── poetry.lock
├── pyproject.toml (27) ├── pyproject.toml (28)
└── README.md └── README.md
``` ```
</div> </div>
@ -112,15 +114,16 @@ sith3/
19. Application principale du projet, contenant sa configuration. 19. Application principale du projet, contenant sa configuration.
20. Gestion des stocks des comptoirs. 20. Gestion des stocks des comptoirs.
21. Gestion des cotisations des utilisateurs du site. 21. Gestion des cotisations des utilisateurs du site.
22. Gestion des trombinoscopes. 22. Fonctionalitées pour gérer le spam.
23. Fichier de configuration de coverage. 23. Gestion des trombinoscopes.
24. Fichier de configuration de direnv. 24. Fichier de configuration de coverage.
25. Fichier généré automatiquement par Django. C'est lui 25. Fichier de configuration de direnv.
26. Fichier généré automatiquement par Django. C'est lui
qui permet d'appeler des commandes de gestion du projet qui permet d'appeler des commandes de gestion du projet
avec la syntaxe `python ./manage.py <nom de la commande>` avec la syntaxe `python ./manage.py <nom de la commande>`
26. Le fichier de configuration de la documentation, 27. Le fichier de configuration de la documentation,
avec ses plugins et sa table des matières. avec ses plugins et sa table des matières.
27. Le fichier où sont déclarés les dépendances et la configuration 28. Le fichier où sont déclarés les dépendances et la configuration
de certaines d'entre elles. de certaines d'entre elles.

View File

@ -19,9 +19,20 @@ from eboutic.models import *
@admin.register(Basket) @admin.register(Basket)
class BasketAdmin(admin.ModelAdmin): class BasketAdmin(admin.ModelAdmin):
list_display = ("user", "date", "get_total") list_display = ("user", "date", "total")
autocomplete_fields = ("user",) autocomplete_fields = ("user",)
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.annotate(
total=Sum(
F("items__quantity") * F("items__product_unit_price"), default=0
)
)
)
@admin.register(BasketItem) @admin.register(BasketItem)
class BasketItemAdmin(admin.ModelAdmin): class BasketItemAdmin(admin.ModelAdmin):

38
eboutic/api.py Normal file
View File

@ -0,0 +1,38 @@
from django.shortcuts import get_object_or_404
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.permissions import IsAuthenticated
from pydantic import NonNegativeInt
from core.models import User
from counter.models import BillingInfo, Customer
from eboutic.models import Basket
from eboutic.schemas import BillingInfoSchema
@api_controller("/etransaction", permissions=[IsAuthenticated])
class EtransactionInfoController(ControllerBase):
@route.put("/billing-info/{user_id}", url_name="put_billing_info")
def put_user_billing_info(self, user_id: NonNegativeInt, info: BillingInfoSchema):
"""Update or create the billing info of this user."""
if user_id == self.context.request.user.id:
user = self.context.request.user
elif self.context.request.user.is_root:
user = get_object_or_404(User, pk=user_id)
else:
raise PermissionDenied
customer, _ = Customer.get_or_create(user)
BillingInfo.objects.update_or_create(
customer=customer, defaults=info.model_dump(exclude_none=True)
)
@route.get("/data", url_name="etransaction_data", include_in_schema=False)
def fetch_etransaction_data(self):
"""Generate the data to pay an eboutic command with paybox.
The data is generated with the basket that is used by the current session.
"""
basket = Basket.from_session(self.context.request.session)
if basket is None:
raise NotFound
return dict(basket.get_e_transaction_data())

View File

@ -20,17 +20,15 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from functools import cached_property
import json
import re
import typing
from urllib.parse import unquote from urllib.parse import unquote
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from sentry_sdk import capture_message from pydantic import ValidationError
from eboutic.models import get_eboutic_products from eboutic.models import get_eboutic_products
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
class BasketForm: class BasketForm:
@ -43,8 +41,7 @@ class BasketForm:
Thus this class is a pure standalone and performs its operations by its own means. Thus this class is a pure standalone and performs its operations by its own means.
However, it still tries to share some similarities with a standard django Form. However, it still tries to share some similarities with a standard django Form.
Example: Examples:
-------
:: ::
def my_view(request): def my_view(request):
@ -62,28 +59,13 @@ class BasketForm:
You can also use a little shortcut by directly calling `form.is_valid()` You can also use a little shortcut by directly calling `form.is_valid()`
without calling `form.clean()`. In this case, the latter method shall be without calling `form.clean()`. In this case, the latter method shall be
implicitly called. implicitly called.
""" """
# check the json is an array containing non-nested objects.
# values must be strings or numbers
# this is matched :
# [{"id": 4, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# but this is not :
# [{"id": {"nested_id": 10}, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# and neither does this :
# [{"id": ["nested_id": 10], "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
# and neither does that :
# [{"id": null, "name": "[PROMO 22] badges", "unit_price": 2.3, "quantity": 2}]
json_cookie_re = re.compile(
r"^\[\s*(\{\s*(\"[^\"]*\":\s*(\"[^\"]{0,64}\"|\d{0,5}\.?\d+),?\s*)*\},?\s*)*\s*\]$"
)
def __init__(self, request: HttpRequest): def __init__(self, request: HttpRequest):
self.user = request.user self.user = request.user
self.cookies = request.COOKIES self.cookies = request.COOKIES
self.error_messages = set() self.error_messages = set()
self.correct_cookie = [] self.correct_items = []
def clean(self) -> None: def clean(self) -> None:
"""Perform all the checks, but return nothing. """Perform all the checks, but return nothing.
@ -98,70 +80,29 @@ class BasketForm:
- all the ids refer to products the user is allowed to buy - all the ids refer to products the user is allowed to buy
- all the quantities are positive integers - all the quantities are positive integers
""" """
# replace escaped double quotes by single quotes, as the RegEx used to check the json try:
# does not support escaped double quotes basket = PurchaseItemList.validate_json(
basket = unquote(self.cookies.get("basket_items", "")).replace('\\"', "'") unquote(self.cookies.get("basket_items", "[]"))
if basket in ("[]", ""):
self.error_messages.add(_("You have no basket."))
return
# check that the json is not nested before parsing it to make sure
# malicious user can't DDoS the server with deeply nested json
if not BasketForm.json_cookie_re.match(basket):
# As the validation of the cookie goes through a rather boring regex,
# we can regularly have to deal with subtle errors that we hadn't forecasted,
# so we explicitly lay a Sentry message capture here.
capture_message(
"Eboutic basket regex checking failed to validate basket json",
level="error",
) )
except ValidationError:
self.error_messages.add(_("The request was badly formatted.")) self.error_messages.add(_("The request was badly formatted."))
return return
if len(basket) == 0:
try:
basket = json.loads(basket)
except json.JSONDecodeError:
self.error_messages.add(_("The basket cookie was badly formatted."))
return
if type(basket) is not list or len(basket) == 0:
self.error_messages.add(_("Your basket is empty.")) self.error_messages.add(_("Your basket is empty."))
return return
existing_ids = {product.id for product in get_eboutic_products(self.user)}
for item in basket: for item in basket:
expected_keys = {"id", "quantity", "name", "unit_price"}
if type(item) is not dict or set(item.keys()) != expected_keys:
self.error_messages.add("One or more items are badly formatted.")
continue
# check the id field is a positive integer
if type(item["id"]) is not int or item["id"] < 0:
self.error_messages.add(
_("%(name)s : this product does not exist.")
% {"name": item["name"]}
)
continue
# check a product with this id does exist # check a product with this id does exist
ids = {product.id for product in get_eboutic_products(self.user)} if item.product_id in existing_ids:
if not item["id"] in ids: self.correct_items.append(item)
else:
self.error_messages.add( self.error_messages.add(
_( _(
"%(name)s : this product does not exist or may no longer be available." "%(name)s : this product does not exist or may no longer be available."
) )
% {"name": item["name"]} % {"name": item.name}
) )
continue continue
if type(item["quantity"]) is not int or item["quantity"] < 0:
self.error_messages.add(
_("You cannot buy %(nbr)d %(name)s.")
% {"nbr": item["quantity"], "name": item["name"]}
)
continue
# if we arrive here, it means this item has passed all tests
self.correct_cookie.append(item)
# for loop for item checking ends here
# this function does not return anything. # this function does not return anything.
# instead, it fills a set containing the collected error messages # instead, it fills a set containing the collected error messages
# an empty set means that no error was seen thus everything is ok # an empty set means that no error was seen thus everything is ok
@ -174,16 +115,16 @@ class BasketForm:
If the `clean()` method has not been called beforehand, call it. If the `clean()` method has not been called beforehand, call it.
""" """
if self.error_messages == set() and self.correct_cookie == []: if not self.error_messages and not self.correct_items:
self.clean() self.clean()
if self.error_messages: if self.error_messages:
return False return False
return True return True
def get_error_messages(self) -> typing.List[str]: @cached_property
def errors(self) -> list[str]:
return list(self.error_messages) return list(self.error_messages)
def get_cleaned_cookie(self) -> str: @cached_property
if not self.correct_cookie: def cleaned_data(self) -> list[PurchaseItemSchema]:
return "" return self.correct_items
return json.dumps(self.correct_cookie)

View File

@ -16,6 +16,7 @@ from __future__ import annotations
import hmac import hmac
from datetime import datetime from datetime import datetime
from typing import Any
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@ -38,6 +39,7 @@ def get_eboutic_products(user: User) -> list[Product]:
.annotate(priority=F("product_type__priority")) .annotate(priority=F("product_type__priority"))
.annotate(category=F("product_type__name")) .annotate(category=F("product_type__name"))
.annotate(category_comment=F("product_type__comment")) .annotate(category_comment=F("product_type__comment"))
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
) )
return [p for p in products if p.can_be_sold_to(user)] return [p for p in products if p.can_be_sold_to(user)]
@ -57,66 +59,25 @@ class Basket(models.Model):
def __str__(self): def __str__(self):
return f"{self.user}'s basket ({self.items.all().count()} items)" return f"{self.user}'s basket ({self.items.all().count()} items)"
def add_product(self, p: Product, q: int = 1):
"""Given p an object of the Product model and q an integer,
add q items corresponding to this Product from the basket.
If this function is called with a product not in the basket, no error will be raised
"""
item = self.items.filter(product_id=p.id).first()
if item is None:
BasketItem(
basket=self,
product_id=p.id,
product_name=p.name,
type_id=p.product_type.id,
quantity=q,
product_unit_price=p.selling_price,
).save()
else:
item.quantity += q
item.save()
def del_product(self, p: Product, q: int = 1):
"""Given p an object of the Product model and q an integer
remove q items corresponding to this Product from the basket.
If this function is called with a product not in the basket, no error will be raised
"""
try:
item = self.items.get(product_id=p.id)
except BasketItem.DoesNotExist:
return
item.quantity -= q
if item.quantity <= 0:
item.delete()
else:
item.save()
def clear(self) -> None:
"""Remove all items from this basket without deleting the basket."""
self.items.all().delete()
@cached_property @cached_property
def contains_refilling_item(self) -> bool: def contains_refilling_item(self) -> bool:
return self.items.filter( return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists() ).exists()
def get_total(self) -> float: @cached_property
total = self.items.aggregate( def total(self) -> float:
total=Sum(F("quantity") * F("product_unit_price")) return float(
)["total"] self.items.aggregate(
return float(total) if total is not None else 0 total=Sum(F("quantity") * F("product_unit_price"), default=0)
)["total"]
)
@classmethod @classmethod
def from_session(cls, session) -> Basket | None: def from_session(cls, session) -> Basket | None:
"""The basket stored in the session object, if it exists.""" """The basket stored in the session object, if it exists."""
if "basket_id" in session: if "basket_id" in session:
try: return cls.objects.filter(id=session["basket_id"]).first()
return cls.objects.get(id=session["basket_id"])
except cls.DoesNotExist:
return None
return None return None
def generate_sales(self, counter, seller: User, payment_method: str): def generate_sales(self, counter, seller: User, payment_method: str):
@ -161,18 +122,24 @@ class Basket(models.Model):
) )
return sales return sales
def get_e_transaction_data(self): def get_e_transaction_data(self) -> list[tuple[str, Any]]:
user = self.user user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
customer = user.customer customer = user.customer
if not hasattr(user.customer, "billing_infos"): if not hasattr(user.customer, "billing_infos"):
raise BillingInfo.DoesNotExist raise BillingInfo.DoesNotExist
cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
}
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
cart, newlines=False
)
data = [ data = [
("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE), ("PBX_SITE", settings.SITH_EBOUTIC_PBX_SITE),
("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG), ("PBX_RANG", settings.SITH_EBOUTIC_PBX_RANG),
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT), ("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.get_total() * 100))), ("PBX_TOTAL", str(int(self.total * 100))),
("PBX_DEVISE", "978"), # This is Euro ("PBX_DEVISE", "978"), # This is Euro
("PBX_CMD", str(self.id)), ("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email), ("PBX_PORTEUR", user.email),
@ -181,14 +148,6 @@ class Basket(models.Model):
("PBX_TYPEPAIEMENT", "CARTE"), ("PBX_TYPEPAIEMENT", "CARTE"),
("PBX_TYPECARTE", "CB"), ("PBX_TYPECARTE", "CB"),
("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")), ("PBX_TIME", datetime.now().replace(microsecond=0).isoformat("T")),
]
cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
}
cart = '<?xml version="1.0" encoding="UTF-8" ?>' + dict2xml(
cart, newlines=False
)
data += [
("PBX_SHOPPINGCART", cart), ("PBX_SHOPPINGCART", cart),
("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()), ("PBX_BILLING", customer.billing_infos.to_3dsv2_xml()),
] ]
@ -218,10 +177,11 @@ class Invoice(models.Model):
return f"{self.user} - {self.get_total()} - {self.date}" return f"{self.user} - {self.get_total()} - {self.date}"
def get_total(self) -> float: def get_total(self) -> float:
total = self.items.aggregate( return float(
total=Sum(F("quantity") * F("product_unit_price")) self.items.aggregate(
)["total"] total=Sum(F("quantity") * F("product_unit_price"), default=0)
return float(total) if total is not None else 0 )["total"]
)
def validate(self): def validate(self):
if self.validated: if self.validated:
@ -284,7 +244,7 @@ class BasketItem(AbstractBaseItem):
) )
@classmethod @classmethod
def from_product(cls, product: Product, quantity: int): def from_product(cls, product: Product, quantity: int, basket: Basket):
"""Create a BasketItem with the same characteristics as the """Create a BasketItem with the same characteristics as the
product passed in parameters, with the specified quantity. product passed in parameters, with the specified quantity.
@ -293,9 +253,10 @@ class BasketItem(AbstractBaseItem):
it yourself before saving the model. it yourself before saving the model.
""" """
return cls( return cls(
basket=basket,
product_id=product.id, product_id=product.id,
product_name=product.name, product_name=product.name,
type_id=product.product_type.id, type_id=product.product_type_id,
quantity=quantity, quantity=quantity,
product_unit_price=product.selling_price, product_unit_price=product.selling_price,
) )

33
eboutic/schemas.py Normal file
View File

@ -0,0 +1,33 @@
from ninja import ModelSchema, Schema
from pydantic import Field, NonNegativeInt, PositiveInt, TypeAdapter
from counter.models import BillingInfo
class PurchaseItemSchema(Schema):
product_id: NonNegativeInt = Field(alias="id")
name: str
unit_price: float
quantity: PositiveInt
# The eboutic deals with data that is dict mixed with JSON.
# Hence it would be a hassle to manage it with a proper Schema class,
# and we use a TypeAdapter instead
PurchaseItemList = TypeAdapter(list[PurchaseItemSchema])
class BillingInfoSchema(ModelSchema):
class Meta:
model = BillingInfo
fields = [
"customer",
"first_name",
"last_name",
"address_1",
"address_2",
"zip_code",
"city",
"country",
]
fields_optional = ["customer"]

View File

@ -30,22 +30,17 @@ function getCookie(name) {
*/ */
function get_starting_items() { function get_starting_items() {
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME); const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
let output = []; if (!cookie) {
return []
try { }
// Django cookie backend does an utter mess on non-trivial data types // Django cookie backend converts `,` to `\054`
// so we must perform a conversion of our own let parsed = JSON.parse(cookie.replace(/\\054/g, ','));
const biscuit = JSON.parse(cookie.replace(/\\054/g, ',')); if (typeof parsed === "string") {
output = Array.isArray(biscuit) ? biscuit : []; // In some conditions, a second parsing is needed
parsed = JSON.parse(parsed);
} catch (e) {} }
const res = Array.isArray(parsed) ? parsed : [];
output.forEach(item => { return res.filter((i) => !!document.getElementById(i.id))
let el = document.getElementById(item.id);
el.classList.add("selected");
});
return output;
} }
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
@ -81,9 +76,6 @@ document.addEventListener('alpine:init', () => {
this.items[index].quantity -= 1; this.items[index].quantity -= 1;
if (this.items[index].quantity === 0) { if (this.items[index].quantity === 0) {
let el = document.getElementById(this.items[index].id);
el.classList.remove("selected");
this.items = this.items.filter((e) => e.id !== this.items[index].id); this.items = this.items.filter((e) => e.id !== this.items[index].id);
} }
this.set_cookies(); this.set_cookies();
@ -93,12 +85,6 @@ document.addEventListener('alpine:init', () => {
* Remove all the items from the basket & cleans the catalog CSS classes * Remove all the items from the basket & cleans the catalog CSS classes
*/ */
clear_basket() { clear_basket() {
// We remove the class "selected" from all the items in the catalog
this.items.forEach(item => {
let el = document.getElementById(item.id);
el.classList.remove("selected");
})
this.items = []; this.items = [];
this.set_cookies(); this.set_cookies();
}, },
@ -108,8 +94,11 @@ document.addEventListener('alpine:init', () => {
* ! the cookie survives an hour * ! the cookie survives an hour
*/ */
set_cookies() { set_cookies() {
if (this.items.length === 0) document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`; if (this.items.length === 0) {
else document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`; document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
} else {
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
}
}, },
/** /**
@ -145,12 +134,10 @@ document.addEventListener('alpine:init', () => {
// if the item is not in the basket, we create it // if the item is not in the basket, we create it
// else we add + 1 to it // else we add + 1 to it
if (item === undefined) item = this.create_item(id, name, price); if (!item) {
else this.add(item); item = this.create_item(id, name, price);
} else {
if (item.quantity > 0) { this.add(item);
let el = document.getElementById(item.id);
el.classList.add("selected");
} }
}, },
})) }))

View File

@ -1,73 +1,70 @@
document.addEventListener('alpine:init', () => { /**
Alpine.store('bank_payment_enabled', false) * @readonly
* @enum {number}
*/
const BillingInfoReqState = {
SUCCESS: 1,
FAILURE: 2,
SENDING: 3,
};
Alpine.store('billing_inputs', { document.addEventListener("alpine:init", () => {
data: JSON.parse(et_data)["data"], Alpine.store("billing_inputs", {
data: et_data,
async fill() { async fill() {
document.getElementById("bank-submit-button").disabled = true; document.getElementById("bank-submit-button").disabled = true;
const request = new Request(et_data_url, { const res = await fetch(et_data_url);
method: "GET",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
});
const res = await fetch(request);
if (res.ok) { if (res.ok) {
const json = await res.json(); this.data = await res.json();
if (json["data"]) {
this.data = json["data"];
}
document.getElementById("bank-submit-button").disabled = false; document.getElementById("bank-submit-button").disabled = false;
} }
} },
}) });
Alpine.data('billing_infos', () => ({ Alpine.data("billing_infos", () => ({
errors: [], /** @type {BillingInfoReqState | null} */
successful: false, req_state: null,
url: billing_info_exist ? edit_billing_info_url : create_billing_info_url,
async send_form() { async send_form() {
this.req_state = BillingInfoReqState.SENDING;
const form = document.getElementById("billing_info_form"); const form = document.getElementById("billing_info_form");
const submit_button = form.querySelector("input[type=submit]")
submit_button.disabled = true;
document.getElementById("bank-submit-button").disabled = true; document.getElementById("bank-submit-button").disabled = true;
this.successful = false let payload = Object.fromEntries(
Array.from(form.querySelectorAll("input, select"))
let payload = {}; .filter((elem) => elem.type !== "submit" && elem.value)
for (const elem of form.querySelectorAll("input")) { .map((elem) => [elem.name, elem.value]),
if (elem.type === "text" && elem.value) { );
payload[elem.name] = elem.value; const res = await fetch(billing_info_url, {
} method: "PUT",
}
const country = form.querySelector("select");
if (country && country.value) {
payload[country.name] = country.value;
}
const request = new Request(this.url, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken(),
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const res = await fetch(request); this.req_state = res.ok
const json = await res.json(); ? BillingInfoReqState.SUCCESS
if (json["errors"]) { : BillingInfoReqState.FAILURE;
this.errors = json["errors"]; if (res.ok) {
} else {
this.errors = [];
this.successful = true;
this.url = edit_billing_info_url;
Alpine.store("billing_inputs").fill(); Alpine.store("billing_inputs").fill();
} }
submit_button.disabled = false; },
}
}))
})
get_alert_color() {
if (this.req_state === BillingInfoReqState.SUCCESS) {
return "green";
}
if (this.req_state === BillingInfoReqState.FAILURE) {
return "red";
}
return "";
},
get_alert_message() {
if (this.req_state === BillingInfoReqState.SUCCESS) {
return billing_info_success_message;
}
if (this.req_state === BillingInfoReqState.FAILURE) {
return billing_info_failure_message;
}
return "";
},
}));
});

View File

@ -12,7 +12,6 @@
{# This script contains the code to perform requests to manipulate the {# This script contains the code to perform requests to manipulate the
user basket without having to reload the page #} user basket without having to reload the page #}
<script src="{{ static('eboutic/js/eboutic.js') }}"></script> <script src="{{ static('eboutic/js/eboutic.js') }}"></script>
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %} {% endblock %}
{% block additional_css %} {% block additional_css %}
@ -30,7 +29,6 @@
{% for error in errors %} {% for error in errors %}
<p style="margin: 0">{{ error }}</p> <p style="margin: 0">{{ error }}</p>
{% endfor %} {% endfor %}
{% trans %}Your basket has been cleaned accordingly to those errors.{% endtrans %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -104,8 +102,12 @@
</div> </div>
<div class="product-group"> <div class="product-group">
{% for p in items %} {% for p in items %}
<button id="{{ p.id }}" class="product-button" <button
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'> id="{{ p.id }}"
class="product-button"
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
>
{% if p.icon %} {% if p.icon %}
<img class="product-image" src="{{ p.icon.url }}" <img class="product-image" src="{{ p.icon.url }}"
alt="image de {{ p.name }}"> alt="image de {{ p.name }}">

View File

@ -10,7 +10,6 @@
{% block additional_js %} {% block additional_js %}
<script src="{{ static('eboutic/js/makecommand.js') }}" defer></script> <script src="{{ static('eboutic/js/makecommand.js') }}" defer></script>
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -38,7 +37,7 @@
</table> </table>
<p> <p>
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.get_total()) }} €</strong> <strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.total) }} €</strong>
{% if customer_amount != None %} {% if customer_amount != None %}
<br> <br>
@ -48,49 +47,54 @@
{% if not basket.contains_refilling_item %} {% if not basket.contains_refilling_item %}
<br> <br>
{% trans %}Remaining account amount: {% endtrans %} {% trans %}Remaining account amount: {% endtrans %}
<strong>{{ "%0.2f"|format(customer_amount|float - basket.get_total()) }} €</strong> <strong>{{ "%0.2f"|format(customer_amount|float - basket.total) }} €</strong>
{% endif %} {% endif %}
{% endif %} {% endif %}
</p> </p>
<br> <br>
{% if settings.SITH_EBOUTIC_CB_ENABLED %} {% if settings.SITH_EBOUTIC_CB_ENABLED %}
<div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak> <div
class="collapse"
:class="{'shadow': collapsed}"
x-data="{collapsed: !billing_info_exist}"
x-cloak
>
<div class="collapse-header clickable" @click="collapsed = !collapsed"> <div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text"> <span class="collapse-header-text">
{% trans %}Edit billing information{% endtrans %} {% trans %}Billing information{% endtrans %}
</span> </span>
<span class="collapse-header-icon" :class="{'reverse': collapsed}"> <span class="collapse-header-icon" :class="{'reverse': collapsed}">
<i class="fa fa-caret-down"></i> <i class="fa fa-caret-down"></i>
</span> </span>
</div> </div>
<form class="collapse-body" id="billing_info_form" method="post" <form
x-show="collapsed" x-data="billing_infos" class="collapse-body"
x-transition.scale.origin.top id="billing_info_form"
@submit.prevent="send_form()"> x-data="billing_infos"
x-show="collapsed"
x-transition.scale.origin.top
@submit.prevent="await send_form()"
>
{% csrf_token %} {% csrf_token %}
{{ billing_form }} {{ billing_form }}
<br> <br>
<br> <br>
<div x-show="errors.length > 0" class="alert alert-red" x-transition> <div
<div class="alert-main"> x-show="[BillingInfoReqState.SUCCESS, BillingInfoReqState.FAILURE].includes(req_state)"
<template x-for="error in errors"> class="alert"
<div x-text="error.field + ' : ' + error.messages.join(', ')"></div> :class="'alert-' + get_alert_color()"
</template> x-transition
</div> >
<div class="clickable" @click="errors = []"> <div class="alert-main" x-text="get_alert_message()"></div>
<div class="clickable" @click="req_state = null">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</div> </div>
</div> </div>
<div x-show="successful" class="alert alert-green" x-transition> <input
<div class="alert-main"> type="submit" class="btn btn-blue clickable"
Informations de facturation enregistrées value="{% trans %}Validate{% endtrans %}"
</div> :disabled="req_state === BillingInfoReqState.SENDING"
<div class="clickable" @click="successful = false"> >
<i class="fa fa-close"></i>
</div>
</div>
<input type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}">
</form> </form>
</div> </div>
<br> <br>
@ -103,16 +107,21 @@
</p> </p>
{% 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="input in $store.billing_inputs.data"> <template x-data x-for="[key, value] in Object.entries($store.billing_inputs.data)">
<input type="hidden" :name="input['key']" :value="input['value']"> <input type="hidden" :name="key" :value="value">
</template> </template>
<input type="submit" id="bank-submit-button" <input
{% if must_fill_billing_infos %}disabled="disabled"{% endif %} type="submit"
value="{% trans %}Pay with credit card{% endtrans %}"/> id="bank-submit-button"
{% if must_fill_billing_infos %}disabled="disabled"{% endif %}
value="{% trans %}Pay with credit card{% endtrans %}"
/>
</form> </form>
{% endif %} {% endif %}
{% if basket.contains_refilling_item %} {% if basket.contains_refilling_item %}
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p> <p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
{% elif basket.total > user.account_balance %}
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
{% else %} {% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form"> <form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
{% csrf_token %} {% csrf_token %}
@ -125,15 +134,16 @@
{% block script %} {% block script %}
<script> <script>
const create_billing_info_url = '{{ url("counter:create_billing_info", user_id=request.user.id) }}' const billing_info_url = '{{ url("api:put_billing_info", user_id=request.user.id) }}';
const edit_billing_info_url = '{{ url("counter:edit_billing_info", user_id=request.user.id) }}'; const et_data_url = '{{ url("api:etransaction_data") }}';
const et_data_url = '{{ url("eboutic:et_data") }}' const billing_info_exist = {{ "true" if billing_infos else "false" }};
let billing_info_exist = {{ "true" if billing_infos else "false" }} const billing_info_success_message = "{% trans %}Billing info registration success{% endtrans %}";
const billing_info_failure_message = "{% trans %}Billing info registration failure{% endtrans %}";
{% if billing_infos %} {% if billing_infos %}
const et_data = {{ billing_infos|tojson }} const et_data = {{ billing_infos|safe }}
{% else %} {% else %}
const et_data = '{"data": []}' const et_data = {}
{% endif %} {% endif %}
</script> </script>
{{ super() }} {{ super() }}

View File

@ -36,7 +36,7 @@ from django.urls import reverse
from core.models import User from core.models import User
from counter.models import Counter, Customer, Product, Selling from counter.models import Counter, Customer, Product, Selling
from eboutic.models import Basket from eboutic.models import Basket, BasketItem
class TestEboutic(TestCase): class TestEboutic(TestCase):
@ -60,14 +60,14 @@ class TestEboutic(TestCase):
basket = Basket.objects.create(user=user) basket = Basket.objects.create(user=user)
session["basket_id"] = basket.id session["basket_id"] = basket.id
session.save() session.save()
basket.add_product(self.barbar, 3) BasketItem.from_product(self.barbar, 3, basket).save()
basket.add_product(self.cotis) BasketItem.from_product(self.cotis, 1, basket).save()
return basket return basket
def generate_bank_valid_answer(self) -> str: def generate_bank_valid_answer(self) -> str:
basket = Basket.from_session(self.client.session) basket = Basket.from_session(self.client.session)
basket_id = basket.id basket_id = basket.id
amount = int(basket.get_total() * 100) amount = int(basket.total * 100)
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000" query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
with open("./eboutic/tests/private_key.pem", "br") as f: with open("./eboutic/tests/private_key.pem", "br") as f:
PRIVKEY = f.read() PRIVKEY = f.read()
@ -88,7 +88,7 @@ class TestEboutic(TestCase):
self.subscriber.customer.amount = 100 # give money before test self.subscriber.customer.amount = 100 # give money before test
self.subscriber.customer.save() self.subscriber.customer.save()
basket = self.get_busy_basket(self.subscriber) basket = self.get_busy_basket(self.subscriber)
amount = basket.get_total() amount = basket.total
response = self.client.post(reverse("eboutic:pay_with_sith")) response = self.client.post(reverse("eboutic:pay_with_sith"))
self.assertRedirects(response, "/eboutic/pay/success/") self.assertRedirects(response, "/eboutic/pay/success/")
new_balance = Customer.objects.get(user=self.subscriber).amount new_balance = Customer.objects.get(user=self.subscriber).amount
@ -99,7 +99,7 @@ class TestEboutic(TestCase):
def test_buy_with_sith_account_no_money(self): def test_buy_with_sith_account_no_money(self):
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
basket = self.get_busy_basket(self.subscriber) basket = self.get_busy_basket(self.subscriber)
initial = basket.get_total() - 1 # just not enough to complete the sale initial = basket.total - 1 # just not enough to complete the sale
self.subscriber.customer.amount = initial self.subscriber.customer.amount = initial
self.subscriber.customer.save() self.subscriber.customer.save()
response = self.client.post(reverse("eboutic:pay_with_sith")) response = self.client.post(reverse("eboutic:pay_with_sith"))
@ -135,7 +135,7 @@ class TestEboutic(TestCase):
cotis = basket.items.filter(product_name="Cotis 2 semestres").first() cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
assert cotis is not None assert cotis is not None
assert cotis.quantity == 1 assert cotis.quantity == 1
assert basket.get_total() == 3 * 1.7 + 28 assert basket.total == 3 * 1.7 + 28
def test_submit_empty_basket(self): def test_submit_empty_basket(self):
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
@ -151,7 +151,7 @@ class TestEboutic(TestCase):
]""" ]"""
response = self.client.get(reverse("eboutic:command")) response = self.client.get(reverse("eboutic:command"))
cookie = self.client.cookies["basket_items"].OutputString() cookie = self.client.cookies["basket_items"].OutputString()
assert 'basket_items=""' in cookie assert 'basket_items="[]"' in cookie
assert "Path=/eboutic" in cookie assert "Path=/eboutic" in cookie
self.assertRedirects(response, "/eboutic/") self.assertRedirects(response, "/eboutic/")

View File

@ -16,7 +16,6 @@
import base64 import base64
import json import json
from datetime import datetime from datetime import datetime
from urllib.parse import unquote
import sentry_sdk import sentry_sdk
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
@ -26,6 +25,7 @@ from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_public_key from cryptography.hazmat.primitives.serialization import load_pem_public_key
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
@ -37,7 +37,14 @@ from django.views.generic import TemplateView, View
from counter.forms import BillingInfoForm from counter.forms import BillingInfoForm
from counter.models import Counter, Customer, Product from counter.models import Counter, Customer, Product
from eboutic.forms import BasketForm from eboutic.forms import BasketForm
from eboutic.models import Basket, Invoice, InvoiceItem, get_eboutic_products from eboutic.models import (
Basket,
BasketItem,
Invoice,
InvoiceItem,
get_eboutic_products,
)
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
@login_required @login_required
@ -75,43 +82,46 @@ 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 EbouticCommand(TemplateView): class EbouticCommand(LoginRequiredMixin, TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja" template_name = "eboutic/eboutic_makecommand.jinja"
basket: Basket
@method_decorator(login_required) @method_decorator(login_required)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
return redirect("eboutic:main") return redirect("eboutic:main")
@method_decorator(login_required)
def get(self, request: HttpRequest, *args, **kwargs): def get(self, request: HttpRequest, *args, **kwargs):
form = BasketForm(request) form = BasketForm(request)
if not form.is_valid(): if not form.is_valid():
request.session["errors"] = form.get_error_messages() request.session["errors"] = form.errors
request.session.modified = True request.session.modified = True
res = redirect("eboutic:main") res = redirect("eboutic:main")
res.set_cookie("basket_items", form.get_cleaned_cookie(), path="/eboutic") res.set_cookie(
"basket_items",
PurchaseItemList.dump_json(form.cleaned_data, by_alias=True).decode(),
path="/eboutic",
)
return res return res
basket = Basket.from_session(request.session) basket = Basket.from_session(request.session)
if basket is not None: if basket is not None:
basket.clear() basket.items.all().delete()
else: else:
basket = Basket.objects.create(user=request.user) basket = Basket.objects.create(user=request.user)
request.session["basket_id"] = basket.id request.session["basket_id"] = basket.id
request.session.modified = True request.session.modified = True
items = json.loads(unquote(request.COOKIES["basket_items"])) items: list[PurchaseItemSchema] = form.cleaned_data
items.sort(key=lambda item: item["id"]) pks = {item.product_id for item in items}
ids = [item["id"] for item in items] products = {p.pk: p for p in Product.objects.filter(pk__in=pks)}
quantities = [item["quantity"] for item in items] db_items = []
products = Product.objects.filter(id__in=ids) for pk in pks:
for product, qty in zip(products, quantities): quantity = sum(i.quantity for i in items if i.product_id == pk)
basket.add_product(product, qty) db_items.append(BasketItem.from_product(products[pk], quantity, basket))
kwargs["basket"] = basket BasketItem.objects.bulk_create(db_items)
return self.render_to_response(self.get_context_data(**kwargs)) self.basket = basket
return super().get(request)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# basket is already in kwargs when the method is called
default_billing_info = None default_billing_info = None
if hasattr(self.request.user, "customer"): if hasattr(self.request.user, "customer"):
customer = self.request.user.customer customer = self.request.user.customer
@ -124,9 +134,8 @@ class EbouticCommand(TemplateView):
if not kwargs["must_fill_billing_infos"]: if not kwargs["must_fill_billing_infos"]:
# the user has already filled its billing_infos, thus we can # the user has already filled its billing_infos, thus we can
# get it without expecting an error # get it without expecting an error
data = kwargs["basket"].get_e_transaction_data() kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
data = {"data": [{"key": key, "value": val} for key, val in data]} kwargs["basket"] = self.basket
kwargs["billing_infos"] = json.dumps(data)
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info) kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
return kwargs return kwargs
@ -149,29 +158,32 @@ def pay_with_sith(request):
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket is None or basket.items.filter(type_id=refilling).exists(): if basket is None or basket.items.filter(type_id=refilling).exists():
return redirect("eboutic:main") return redirect("eboutic:main")
c = Customer.objects.filter(user__id=basket.user.id).first() c = Customer.objects.filter(user__id=basket.user_id).first()
if c is None: if c is None:
return redirect("eboutic:main") return redirect("eboutic:main")
if c.amount < basket.get_total(): if c.amount < basket.total:
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
eboutic = Counter.objects.get(type="EBOUTIC")
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
try:
with transaction.atomic():
# Selling.save has some important business logic in it.
# Do not bulk_create this
for sale in sales:
sale.save()
basket.delete()
request.session.pop("basket_id", None)
res = redirect("eboutic:payment_result", "success")
except DatabaseError as e:
with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username}
scope.set_extra("someVariable", e.__repr__())
sentry_sdk.capture_message(
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
)
res = redirect("eboutic:payment_result", "failure") res = redirect("eboutic:payment_result", "failure")
else:
eboutic = Counter.objects.filter(type="EBOUTIC").first()
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
try:
with transaction.atomic():
for sale in sales:
sale.save()
basket.delete()
request.session.pop("basket_id", None)
res = redirect("eboutic:payment_result", "success")
except DatabaseError as e:
with sentry_sdk.push_scope() as scope:
scope.user = {"username": request.user.username}
scope.set_extra("someVariable", e.__repr__())
sentry_sdk.capture_message(
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
)
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic") res.delete_cookie("basket_items", "/eboutic")
return res return res
@ -205,7 +217,7 @@ class EtransactionAutoAnswer(View):
) )
if b is None: if b is None:
raise SuspiciousOperation("Basket does not exists") raise SuspiciousOperation("Basket does not exists")
if int(b.get_total() * 100) != int(request.GET["Amount"]): if int(b.total * 100) != int(request.GET["Amount"]):
raise SuspiciousOperation( raise SuspiciousOperation(
"Basket total and amount do not match" "Basket total and amount do not match"
) )

View File

@ -21,6 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import logging
import math import math
from functools import partial from functools import partial
@ -424,7 +425,7 @@ class ForumMessageCreateView(CanCreateMixin, CreateView):
) )
init["message"] += "\n\n" init["message"] += "\n\n"
except Exception as e: except Exception as e:
print(repr(e)) logging.error(e)
return init return init
def form_valid(self, form): def form_valid(self, form):

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,7 @@ nav:
- Archives: explanation/archives.md - Archives: explanation/archives.md
- Tutoriels: - Tutoriels:
- Installer le projet: tutorial/install.md - Installer le projet: tutorial/install.md
- Installer le projet (avancé): tutorial/install-advanced.md
- Configurer son éditeur: tutorial/devtools.md - Configurer son éditeur: tutorial/devtools.md
- Structure du projet: tutorial/structure.md - Structure du projet: tutorial/structure.md
- Gestion des permissions: tutorial/perms.md - Gestion des permissions: tutorial/perms.md
@ -80,6 +81,9 @@ nav:
- accounting: - accounting:
- reference/accounting/models.md - reference/accounting/models.md
- reference/accounting/views.md - reference/accounting/views.md
- antispam:
- reference/antispam/models.md
- reference/antispam/forms.md
- club: - club:
- reference/club/models.md - reference/club/models.md
- reference/club/views.md - reference/club/views.md

View File

@ -36,11 +36,7 @@ def remove_multiples_comments_from_same_user(apps, schema_editor):
.order_by("-publish_date") .order_by("-publish_date")
.first() .first()
) )
for comment in ( user.uv_comments.filter(uv__id=uv["uv"]).exclude(pk=last.pk).delete()
user.uv_comments.filter(uv__id=uv["uv"]).exclude(pk=last.pk).all()
):
print("removing : %s" % (comment,))
comment.delete()
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -4,10 +4,6 @@
{% trans %}UV Guide{% endtrans %} {% trans %}UV Guide{% endtrans %}
{% endblock %} {% endblock %}
{% block additional_js %}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ scss('pedagogy/css/pedagogy.scss') }}"> <link rel="stylesheet" href="{{ scss('pedagogy/css/pedagogy.scss') }}">
<link rel="stylesheet" href="{{ scss('core/pagination.scss') }}"> <link rel="stylesheet" href="{{ scss('core/pagination.scss') }}">
@ -100,7 +96,7 @@
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
<tbody id="dynamic_view_content"> <tbody id="dynamic_view_content" :aria-busy="loading">
<template x-for="uv in uvs.results" :key="uv.id"> <template x-for="uv in uvs.results" :key="uv.id">
<tr @click="window.location.href = `/pedagogy/uv/${uv.id}`" class="clickable"> <tr @click="window.location.href = `/pedagogy/uv/${uv.id}`" class="clickable">
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td> <td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
@ -130,22 +126,6 @@
</nav> </nav>
</div> </div>
<script> <script>
const initialUrlParams = new URLSearchParams(window.location.search);
function update_query_string(key, value) {
const url = new URL(window.location.href);
if (!value) {
{# If the value is null, undefined or empty => delete it #}
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
url.searchParams.delete(key)
value.forEach((v) => url.searchParams.append(key, v))
} else {
url.searchParams.set(key, value);
}
history.pushState(null, document.title, url.toString());
}
{# {#
How does this work : How does this work :
@ -160,6 +140,7 @@
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("uv_search", () => ({ Alpine.data("uv_search", () => ({
uvs: [], uvs: [],
loading: false,
page: parseInt(initialUrlParams.get("page")) || page_default, page: parseInt(initialUrlParams.get("page")) || page_default,
page_size: parseInt(initialUrlParams.get("page_size")) || page_size_default, page_size: parseInt(initialUrlParams.get("page_size")) || page_size_default,
search: initialUrlParams.get("search") || "", search: initialUrlParams.get("search") || "",
@ -191,8 +172,10 @@
}, },
async fetch_data() { async fetch_data() {
this.loading = true;
const url = "{{ url("api:fetch_uvs") }}" + window.location.search; const url = "{{ url("api:fetch_uvs") }}" + window.location.search;
this.uvs = await (await fetch(url)).json(); this.uvs = await (await fetch(url)).json();
this.loading = false;
}, },
max_page() { max_page() {

135
poetry.lock generated
View File

@ -574,17 +574,17 @@ testing = ["coverage", "geopy (==2)", "pysolr (>=3.7)", "python-dateutil", "requ
[[package]] [[package]]
name = "django-honeypot" name = "django-honeypot"
version = "1.2.0" version = "1.2.1"
description = "Django honeypot field utilities" description = "Django honeypot field utilities"
optional = false optional = false
python-versions = "<4.0,>=3.8" python-versions = "<4.0,>=3.8"
files = [ files = [
{file = "django_honeypot-1.2.0-py3-none-any.whl", hash = "sha256:53dd5f8dd96ef1bb7e31b5514c0dc2caae9577e78ebdf03ca4e0f304a7422aba"}, {file = "django_honeypot-1.2.1-py3-none-any.whl", hash = "sha256:fdabe4ded66b6db25d04af2446de3bf7cb047cb5db097a864c8c97b081ef736f"},
{file = "django_honeypot-1.2.0.tar.gz", hash = "sha256:25fca02e786aec26649bd13b37a95c846e09ab3cfc10f28db2f7dfaa77b9b9c6"}, {file = "django_honeypot-1.2.1.tar.gz", hash = "sha256:ab5c2aad214d86def2f00f6a79aa14f171db7301ac8712f20dc21a83dd5d6413"},
] ]
[package.dependencies] [package.dependencies]
Django = ">=3.2,<5.1" Django = ">=3.2,<5.2"
[[package]] [[package]]
name = "django-jinja" name = "django-jinja"
@ -754,13 +754,13 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth
[[package]] [[package]]
name = "faker" name = "faker"
version = "26.0.0" version = "26.1.0"
description = "Faker is a Python package that generates fake data for you." description = "Faker is a Python package that generates fake data for you."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "Faker-26.0.0-py3-none-any.whl", hash = "sha256:886ee28219be96949cd21ecc96c4c742ee1680e77f687b095202c8def1a08f06"}, {file = "Faker-26.1.0-py3-none-any.whl", hash = "sha256:e8c5ef795223e945d9166aea3c0ecaf85ac54b4ade2af068d8e3c6524c2c0aa7"},
{file = "Faker-26.0.0.tar.gz", hash = "sha256:0f60978314973de02c00474c2ae899785a42b2cf4f41b7987e93c132a2b8a4a9"}, {file = "Faker-26.1.0.tar.gz", hash = "sha256:33921b6fc3b83dd75fd42ec7f47ec87b50c00d3c5380fa7d8a507dab848b8229"},
] ]
[package.dependencies] [package.dependencies]
@ -1454,13 +1454,13 @@ ptyprocess = ">=0.5"
[[package]] [[package]]
name = "phonenumbers" name = "phonenumbers"
version = "8.13.40" version = "8.13.42"
description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers." description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers."
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
{file = "phonenumbers-8.13.40-py2.py3-none-any.whl", hash = "sha256:9582752c20a1da5ec4449f7f97542bf8a793c8e2fec0ab57f767177bb8fc0b1d"}, {file = "phonenumbers-8.13.42-py2.py3-none-any.whl", hash = "sha256:18acc22ee03116d27b26e990f53806a1770a3e05f05e1620bc09ad187f889456"},
{file = "phonenumbers-8.13.40.tar.gz", hash = "sha256:f137c2848b8e83dd064b71881b65680584417efa202177fd330e2f7ff6c68113"}, {file = "phonenumbers-8.13.42.tar.gz", hash = "sha256:7137904f2db3b991701e853174ce8e1cb8f540b8bfdf27617540de04c0b7bed5"},
] ]
[[package]] [[package]]
@ -1624,84 +1624,37 @@ files = [
wcwidth = "*" wcwidth = "*"
[[package]] [[package]]
name = "psycopg2-binary" name = "psycopg"
version = "2.9.9" version = "3.2.1"
description = "psycopg2 - Python-PostgreSQL Database Adapter" description = "PostgreSQL database adapter for Python"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"}, {file = "psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"}, {file = "psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"}, ]
{file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"}, [package.dependencies]
{file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"}, psycopg-c = {version = "3.2.1", optional = true, markers = "implementation_name != \"pypy\" and extra == \"c\""}
{file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"}, typing-extensions = ">=4.4"
{file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"}, tzdata = {version = "*", markers = "sys_platform == \"win32\""}
{file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"},
{file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"}, [package.extras]
{file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"}, binary = ["psycopg-binary (==3.2.1)"]
{file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"}, c = ["psycopg-c (==3.2.1)"]
{file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"}, dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.6)", "types-setuptools (>=57.4)", "wheel (>=0.37)"]
{file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"}, docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"]
{file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"}, pool = ["psycopg-pool"]
{file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"}, test = ["anyio (>=4.0)", "mypy (>=1.6)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"]
{file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"},
{file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"}, [[package]]
{file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"}, name = "psycopg-c"
{file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"}, version = "3.2.1"
{file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"}, description = "PostgreSQL database adapter for Python -- C optimisation distribution"
{file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"}, optional = false
{file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"}, python-versions = ">=3.8"
{file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"}, files = [
{file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"}, {file = "psycopg_c-3.2.1.tar.gz", hash = "sha256:2d09943cc8a855c42c1e23b4298957b7ce8f27bf3683258c52fd139f601f7cda"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"},
{file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"},
{file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"},
{file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"},
{file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"},
] ]
[[package]] [[package]]
@ -2228,13 +2181,13 @@ files = [
[[package]] [[package]]
name = "sentry-sdk" name = "sentry-sdk"
version = "2.11.0" version = "2.12.0"
description = "Python client for Sentry (https://sentry.io)" description = "Python client for Sentry (https://sentry.io)"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
{file = "sentry_sdk-2.11.0-py2.py3-none-any.whl", hash = "sha256:d964710e2dbe015d9dc4ff0ad16225d68c3b36936b742a6fe0504565b760a3b7"}, {file = "sentry_sdk-2.12.0-py2.py3-none-any.whl", hash = "sha256:7a8d5163d2ba5c5f4464628c6b68f85e86972f7c636acc78aed45c61b98b7a5e"},
{file = "sentry_sdk-2.11.0.tar.gz", hash = "sha256:4ca16e9f5c7c6bc2fb2d5c956219f4926b148e511fffdbbde711dc94f1e0468f"}, {file = "sentry_sdk-2.12.0.tar.gz", hash = "sha256:8763840497b817d44c49b3fe3f5f7388d083f2337ffedf008b2cdb63b5c86dc6"},
] ]
[package.dependencies] [package.dependencies]
@ -2264,7 +2217,7 @@ langchain = ["langchain (>=0.0.210)"]
loguru = ["loguru (>=0.5)"] loguru = ["loguru (>=0.5)"]
openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"]
opentelemetry = ["opentelemetry-distro (>=0.35b0)"] opentelemetry = ["opentelemetry-distro (>=0.35b0)"]
opentelemetry-experimental = ["opentelemetry-instrumentation-aio-pika (==0.46b0)", "opentelemetry-instrumentation-aiohttp-client (==0.46b0)", "opentelemetry-instrumentation-aiopg (==0.46b0)", "opentelemetry-instrumentation-asgi (==0.46b0)", "opentelemetry-instrumentation-asyncio (==0.46b0)", "opentelemetry-instrumentation-asyncpg (==0.46b0)", "opentelemetry-instrumentation-aws-lambda (==0.46b0)", "opentelemetry-instrumentation-boto (==0.46b0)", "opentelemetry-instrumentation-boto3sqs (==0.46b0)", "opentelemetry-instrumentation-botocore (==0.46b0)", "opentelemetry-instrumentation-cassandra (==0.46b0)", "opentelemetry-instrumentation-celery (==0.46b0)", "opentelemetry-instrumentation-confluent-kafka (==0.46b0)", "opentelemetry-instrumentation-dbapi (==0.46b0)", "opentelemetry-instrumentation-django (==0.46b0)", "opentelemetry-instrumentation-elasticsearch (==0.46b0)", "opentelemetry-instrumentation-falcon (==0.46b0)", "opentelemetry-instrumentation-fastapi (==0.46b0)", "opentelemetry-instrumentation-flask (==0.46b0)", "opentelemetry-instrumentation-grpc (==0.46b0)", "opentelemetry-instrumentation-httpx (==0.46b0)", "opentelemetry-instrumentation-jinja2 (==0.46b0)", "opentelemetry-instrumentation-kafka-python (==0.46b0)", "opentelemetry-instrumentation-logging (==0.46b0)", "opentelemetry-instrumentation-mysql (==0.46b0)", "opentelemetry-instrumentation-mysqlclient (==0.46b0)", "opentelemetry-instrumentation-pika (==0.46b0)", "opentelemetry-instrumentation-psycopg (==0.46b0)", "opentelemetry-instrumentation-psycopg2 (==0.46b0)", "opentelemetry-instrumentation-pymemcache (==0.46b0)", "opentelemetry-instrumentation-pymongo (==0.46b0)", "opentelemetry-instrumentation-pymysql (==0.46b0)", "opentelemetry-instrumentation-pyramid (==0.46b0)", "opentelemetry-instrumentation-redis (==0.46b0)", "opentelemetry-instrumentation-remoulade (==0.46b0)", "opentelemetry-instrumentation-requests (==0.46b0)", "opentelemetry-instrumentation-sklearn (==0.46b0)", "opentelemetry-instrumentation-sqlalchemy (==0.46b0)", "opentelemetry-instrumentation-sqlite3 (==0.46b0)", "opentelemetry-instrumentation-starlette (==0.46b0)", "opentelemetry-instrumentation-system-metrics (==0.46b0)", "opentelemetry-instrumentation-threading (==0.46b0)", "opentelemetry-instrumentation-tornado (==0.46b0)", "opentelemetry-instrumentation-tortoiseorm (==0.46b0)", "opentelemetry-instrumentation-urllib (==0.46b0)", "opentelemetry-instrumentation-urllib3 (==0.46b0)", "opentelemetry-instrumentation-wsgi (==0.46b0)"] opentelemetry-experimental = ["opentelemetry-distro"]
pure-eval = ["asttokens", "executing", "pure-eval"] pure-eval = ["asttokens", "executing", "pure-eval"]
pymongo = ["pymongo (>=3.1)"] pymongo = ["pymongo (>=3.1)"]
pyspark = ["pyspark (>=2.4.4)"] pyspark = ["pyspark (>=2.4.4)"]
@ -2632,4 +2585,4 @@ filelock = ">=3.4"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "a9573a584420b00b0bd5bb85a0fb2daedb365bd1ff604b94ec23c187bc4dd991" content-hash = "30107b3b01a30323c162e09f556fd56ca8e2c338e875d7ea87583a25195645a7"

View File

@ -29,7 +29,7 @@ mistune = "^3.0.2"
django-jinja = "^2.11" django-jinja = "^2.11"
cryptography = "^43.0.0" cryptography = "^43.0.0"
django-phonenumber-field = "^8.0.0" django-phonenumber-field = "^8.0.0"
phonenumbers = "^8.12" phonenumbers = "^8.13"
django-ajax-selects = "^2.2.1" django-ajax-selects = "^2.2.1"
reportlab = "^4.2" reportlab = "^4.2"
django-haystack = "^3.2.1" django-haystack = "^3.2.1"
@ -38,18 +38,21 @@ libsass = "^0.23"
django-ordered-model = "^3.7" django-ordered-model = "^3.7"
django-simple-captcha = "^0.6.0" django-simple-captcha = "^0.6.0"
python-dateutil = "^2.8.2" python-dateutil = "^2.8.2"
sentry-sdk = "^2.11.0" sentry-sdk = "^2.12.0"
pygraphviz = "^1.1" pygraphviz = "^1.1"
Jinja2 = "^3.1" Jinja2 = "^3.1"
django-countries = "^7.5.1" django-countries = "^7.5.1"
dict2xml = "^1.7.3" 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.0" django-honeypot = "^1.2.1"
[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
psycopg2-binary = "^2.9"
# The C extra triggers compilation against sytem libs during install.
# Removing it would switch psycopg to a slower full-python implementation
psycopg = {extras = ["c"], version = "^3.2.1"}
redis = {extras = ["hiredis"], version = "^5.0.8"} redis = {extras = ["hiredis"], version = "^5.0.8"}
[tool.poetry.group.prod] [tool.poetry.group.prod]
@ -62,7 +65,7 @@ ipython = "^8.26.0"
pre-commit = "^3.8.0" pre-commit = "^3.8.0"
ruff = "^0.5.5" # Version used in pipeline is controlled by pre-commit hooks in .pre-commit.config.yaml ruff = "^0.5.5" # Version used in pipeline is controlled by pre-commit hooks in .pre-commit.config.yaml
djhtml = "^3.0.6" djhtml = "^3.0.6"
faker = "^26.0.0" faker = "^26.1.0"
[tool.poetry.group.tests.dependencies] [tool.poetry.group.tests.dependencies]
# deps used for testing purposes # deps used for testing purposes
@ -100,6 +103,7 @@ select = [
"FBT", # boolean trap "FBT", # boolean trap
"UP008", # Use super() instead of super(__class__, self) "UP008", # Use super() instead of super(__class__, self)
"UP009", # utf-8 encoding declaration is unnecessary "UP009", # utf-8 encoding declaration is unnecessary
"T2", # print statements
] ]
ignore = [ ignore = [

View File

@ -40,7 +40,7 @@ class Command(BaseCommand):
user = User.objects.filter(id=options["user_id"]).first() user = User.objects.filter(id=options["user_id"]).first()
if user is None: if user is None:
print("User with ID %s not found" % (options["user_id"],)) self.stderr.write("User with ID %s not found" % (options["user_id"],))
exit(1) exit(1)
confirm = input( confirm = input(
@ -49,7 +49,7 @@ class Command(BaseCommand):
) )
if not confirm.lower().startswith("y"): if not confirm.lower().startswith("y"):
print("Operation aborted") self.stderr.write("Operation aborted")
exit(1) exit(1)
delete_all_forum_user_messages(user, User.objects.get(id=0), verbose=True) delete_all_forum_user_messages(user, User.objects.get(id=0), verbose=True)

View File

@ -21,6 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import logging
from ajax_select.fields import AutoCompleteSelectField from ajax_select.fields import AutoCompleteSelectField
from django import forms from django import forms
@ -146,7 +147,7 @@ def delete_all_forum_user_messages(user, moderator, *, verbose=False):
continue continue
if verbose: if verbose:
print(message) logging.getLogger("django").info(message)
ForumMessageMeta(message=message, user=moderator, action="DELETE").save() ForumMessageMeta(message=message, user=moderator, action="DELETE").save()

View File

@ -1,9 +1,11 @@
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.models import User from core.models import User
@ -15,10 +17,11 @@ from sas.schemas import PictureFilterSchema, PictureSchema
class PicturesController(ControllerBase): class PicturesController(ControllerBase):
@route.get( @route.get(
"", "",
response=list[PictureSchema], response=PaginatedResponseSchema[PictureSchema],
permissions=[IsAuthenticated], permissions=[IsAuthenticated],
url_name="pictures", url_name="pictures",
) )
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_pictures(self, filters: Query[PictureFilterSchema]): def fetch_pictures(self, filters: Query[PictureFilterSchema]):
"""Find pictures viewable by the user corresponding to the given filters. """Find pictures viewable by the user corresponding to the given filters.
@ -38,23 +41,12 @@ class PicturesController(ControllerBase):
cf. https://ae.utbm.fr/user/32663/pictures/) cf. https://ae.utbm.fr/user/32663/pictures/)
""" """
user: User = self.context.request.user user: User = self.context.request.user
if not user.is_subscribed and filters.users_identified != {user.id}: return (
# User can view any moderated picture if he/she is subscribed. filters.filter(Picture.objects.viewable_by(user))
# If not, he/she can view only the one he/she has been identified on
raise PermissionDenied
pictures = list(
filters.filter(
Picture.objects.filter(is_moderated=True, asked_for_removal=False)
)
.distinct() .distinct()
.order_by("-date") .order_by("-parent__date", "date")
.annotate(album=F("parent__name")) .annotate(album=F("parent__name"))
) )
for picture in pictures:
picture.full_size_url = picture.get_download_url()
picture.compressed_url = picture.get_download_compressed_url()
picture.thumb_url = picture.get_download_thumb_url()
return pictures
@api_controller("/sas/relation", tags="User identification on SAS pictures") @api_controller("/sas/relation", tags="User identification on SAS pictures")

View File

@ -13,11 +13,14 @@
# #
# #
from __future__ import annotations
from io import BytesIO from io import BytesIO
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db import models from django.db import models
from django.db.models import Exists, OuterRef
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -27,21 +30,60 @@ from core.models import SithFile, User
from core.utils import exif_auto_rotate, resize_image from core.utils import exif_auto_rotate, resize_image
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
May be used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album].
"""
class Meta:
proxy = True
def can_be_viewed_by(self, user):
if user.is_anonymous:
return False
cache_key = (
f"sas:{self._meta.model_name}_viewable_by_{user.id}_in_{self.parent_id}"
)
viewable: list[int] | None = cache.get(cache_key)
if viewable is None:
viewable = list(
self.__class__.objects.filter(parent_id=self.parent_id)
.viewable_by(user)
.values_list("pk", flat=True)
)
cache.set(cache_key, viewable, timeout=10)
return self.id in viewable
def can_be_edited_by(self, user):
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> PictureQuerySet:
"""Filter the pictures that this user can view.
Warnings:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager): class SASPictureManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False) return super().get_queryset().filter(is_in_sas=True, is_folder=False)
class SASAlbumManager(models.Manager): class Picture(SasFile):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Picture(SithFile):
class Meta: class Meta:
proxy = True proxy = True
objects = SASPictureManager() objects = SASPictureManager.from_queryset(PictureQuerySet)()
@property @property
def is_vertical(self): def is_vertical(self):
@ -50,29 +92,6 @@ class Picture(SithFile):
(w, h) = im.size (w, h) = im.size
return (w / h) < 1 return (w / h) < 1
def can_be_edited_by(self, user):
perm = cache.get("%d_can_edit_pictures" % (user.id), None)
if perm is None:
perm = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
cache.set("%d_can_edit_pictures" % (user.id), perm, timeout=4)
return perm
def can_be_viewed_by(self, user):
# SAS pictures are visible to old subscribers
# Result is cached 4s for this user
if user.is_anonymous:
return False
perm = cache.get("%d_can_view_pictures" % (user.id), False)
if not perm:
perm = user.was_subscribed
cache.set("%d_can_view_pictures" % (user.id), perm, timeout=4)
return (perm and self.is_moderated and self.is_in_sas) or self.can_be_edited_by(
user
)
def get_download_url(self): def get_download_url(self):
return reverse("sas:download", kwargs={"picture_id": self.id}) return reverse("sas:download", kwargs={"picture_id": self.id})
@ -124,48 +143,53 @@ class Picture(SithFile):
def get_next(self): def get_next(self):
if self.is_moderated: if self.is_moderated:
return ( pictures_qs = self.parent.children.filter(
self.parent.children.filter( is_moderated=True,
is_moderated=True, asked_for_removal=False,
asked_for_removal=False, is_folder=False,
is_folder=False, id__gt=self.id,
id__gt=self.id,
)
.order_by("id")
.first()
) )
else: else:
return ( pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
Picture.objects.filter(id__gt=self.id, is_moderated=False) return pictures_qs.order_by("id").first()
.order_by("id")
.first()
)
def get_previous(self): def get_previous(self):
if self.is_moderated: if self.is_moderated:
return ( pictures_qs = self.parent.children.filter(
self.parent.children.filter( is_moderated=True,
is_moderated=True, asked_for_removal=False,
asked_for_removal=False, is_folder=False,
is_folder=False, id__lt=self.id,
id__lt=self.id,
)
.order_by("id")
.last()
) )
else: else:
return ( pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
Picture.objects.filter(id__lt=self.id, is_moderated=False) return pictures_qs.order_by("-id").first()
.order_by("-id")
.first()
)
class Album(SithFile): class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> PictureQuerySet:
"""Filter the albums that this user can view.
Warnings:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
class Meta: class Meta:
proxy = True proxy = True
objects = SASAlbumManager() objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
@property @property
def children_pictures(self): def children_pictures(self):
@ -175,15 +199,6 @@ class Album(SithFile):
def children_albums(self): def children_albums(self):
return Album.objects.filter(parent=self) return Album.objects.filter(parent=self)
def can_be_edited_by(self, user):
return user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
def can_be_viewed_by(self, user):
# file = SithFile.objects.filter(id=self.id).first()
return self.can_be_edited_by(user) or (
self.is_in_sas and self.is_moderated and user.was_subscribed
) # or user.can_view(file)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("sas:album", kwargs={"album_id": self.id}) return reverse("sas:album", kwargs={"album_id": self.id})

View File

@ -3,7 +3,6 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt from pydantic import Field, NonNegativeInt
from core.schemas import SimpleUserSchema
from sas.models import PeoplePictureRelation, Picture from sas.models import PeoplePictureRelation, Picture
@ -17,14 +16,25 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema): class PictureSchema(ModelSchema):
class Meta: class Meta:
model = Picture model = Picture
fields = ["id", "name", "date", "size"] fields = ["id", "name", "date", "size", "is_moderated"]
author: SimpleUserSchema = Field(validation_alias="owner")
full_size_url: str full_size_url: str
compressed_url: str compressed_url: str
thumb_url: str thumb_url: str
album: str album: str
@staticmethod
def resolve_full_size_url(obj: Picture) -> str:
return obj.get_download_url()
@staticmethod
def resolve_compressed_url(obj: Picture) -> str:
return obj.get_download_compressed_url()
@staticmethod
def resolve_thumb_url(obj: Picture) -> str:
return obj.get_download_thumb_url()
class PictureCreateRelationSchema(Schema): class PictureCreateRelationSchema(Schema):
user_id: NonNegativeInt user_id: NonNegativeInt

View File

@ -1,20 +1,15 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import paginate %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ scss('sas/album.scss') }}"> <link rel="stylesheet" href="{{ scss('sas/album.scss') }}">
<link rel="stylesheet" href="{{ scss('core/pagination.scss') }}">
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}
{% trans %}SAS{% endtrans %} {% trans %}SAS{% endtrans %}
{% endblock %} {% endblock %}
{% macro print_path(file) %} {% from "sas/macros.jinja" import display_album, print_path %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}
{% block content %} {% block content %}
@ -22,10 +17,10 @@
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }} <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }}
</code> </code>
{% set edit_mode = user.can_edit(album) %} {% set is_sas_admin = user.can_edit(album) %}
{% set start = timezone.now() %} {% set start = timezone.now() %}
{% if edit_mode %} {% if is_sas_admin %}
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
@ -53,73 +48,63 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if album.children_albums.count() > 0 %} {% if children_albums|length > 0 %}
<h4>{% trans %}Albums{% endtrans %}</h4> <h4>{% trans %}Albums{% endtrans %}</h4>
<div class="albums"> <div class="albums">
{% for a in album.children_albums.order_by('-date') %} {% for a in children_albums %}
{% if a.can_be_viewed_by(user) %} {{ display_album(a, is_sas_admin) }}
<a href="{{ url('sas:album', album_id=a.id) }}">
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
style="background-image: url('{% if a.file %}{{ a.get_download_url() }}{% else %}{{ static('core/img/sas.jpg') }}{% endif %}');"
>
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %}
<div class="text">{{ a.name }}</div>
{% endif %}
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %}
</a>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<br> <br>
{% endif %} {% endif %}
<h4>{% trans %}Pictures{% endtrans %}</h4> <div x-data="pictures">
{% if pictures | length != 0 %} <h4>{% trans %}Pictures{% endtrans %}</h4>
<div class="photos"> <div class="photos" :aria-busy="loading">
{% for p in pictures %} <template x-for="picture in pictures.results">
{% if p.can_be_viewed_by(user) %} <a :href="`/sas/picture/${picture.id}#pict`">
<a href="{{ url('sas:picture', picture_id=p.id) }}#pict"> <div class="photo" :style="`background-image: url(${picture.thumb_url})`">
<div <template x-if="!picture.is_moderated">
class="photo {% if p.is_vertical %}vertical{% endif %}" <div class="overlay">&nbsp;</div>
style="background-image: url('{{ p.get_download_thumb_url() }}')" <div class="text">{% trans %}To be moderated{% endtrans %}</div>
> </template>
{% if not p.is_moderated %} <template x-if="picture.is_moderated">
<div class="overlay">&nbsp;</div> <div class="text">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div> </template>
{% else %} </div>
<div class="text">&nbsp;</div> {% if is_sas_admin %}
{% endif %} <input type="checkbox" name="file_list" :value="picture.id">
</div> {% endif %}
{% if edit_mode %} </a>
<input type="checkbox" name="file_list" value="{{ p.id }}"> </template>
{% endif %}
</a>
{% endif %}
{% endfor %}
</div> </div>
{% else %} <nav class="pagination" x-show="nb_pages() > 1">
{% trans %}This album does not contain any photos.{% endtrans %} {# Adding the prevent here is important, because otherwise,
{% endif %} clicking on the pagination buttons could submit the picture management form
and reload the page #}
<button
@click.prevent="page--"
:disabled="page <= 1"
@keyup.right.window="page = Math.min(nb_pages(), page + 1)"
>
<i class="fa fa-caret-left"></i>
</button>
<template x-for="i in nb_pages()">
<button x-text="i" @click.prevent="page = i" :class="{active: page === i}"></button>
</template>
<button
@click.prevent="page++"
:disabled="page >= nb_pages()"
@keyup.left.window="page = Math.max(1, page - 1)"
>
<i class="fa fa-caret-right"></i>
</button>
</nav>
</div>
{% if pictures.has_previous() or pictures.has_next() %} {% if is_sas_admin %}
<div class="paginator">
{{ paginate(pictures, paginator) }}
</div>
{% endif %}
{% if edit_mode %}
</form> </form>
{% endif %}
{% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
<form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data"> <form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="inputs"> <div class="inputs">
@ -140,6 +125,36 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
document.addEventListener("alpine:init", () => {
Alpine.data("pictures", () => ({
pictures: {},
page: parseInt(initialUrlParams.get("page")) || 1,
loading: false,
async init() {
await this.fetch_pictures();
this.$watch("page", () => {
update_query_string("page", this.page === 1 ? null : this.page);
this.fetch_pictures()
});
},
async fetch_pictures() {
this.loading=true;
const url = "{{ url("api:pictures") }}"
+"?album_id={{ album.id }}"
+`&page=${this.page}`
+"&page_size={{ settings.SITH_SAS_IMAGES_PER_PAGE }}";
this.pictures = await (await fetch(url)).json();
this.loading=false;
},
nb_pages() {
return Math.ceil(this.pictures.count / {{ settings.SITH_SAS_IMAGES_PER_PAGE }});
}
}))
})
$("form#upload_form").submit(function (event) { $("form#upload_form").submit(function (event) {
let formData = new FormData($(this)[0]); let formData = new FormData($(this)[0]);

View File

@ -0,0 +1,32 @@
{% macro display_album(a, edit_mode) %}
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% endif %}
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
style="background-image: url('{{ img }}');"
>
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
{% else %}
<div class="text">{{ a.name }}</div>
{% endif %}
</div>
{% if edit_mode %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %}
</a>
{% endmacro %}
{% macro print_path(file) %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}

View File

@ -8,31 +8,9 @@
{% trans %}SAS{% endtrans %} {% trans %}SAS{% endtrans %}
{% endblock %} {% endblock %}
{% set edit_mode = user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %} {% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% macro display_album(a, checkbox) %} {% from "sas/macros.jinja" import display_album %}
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %}
{% set img = a.get_download_url() %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set img = a.children.filter(is_folder=False).first().as_picture.get_download_thumb_url() %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% endif %}
<div
class="album"
style="background-image: url('{{ img }}');"
>
<div class="text">
{{ a.name }}
</div>
</div>
{# {% if edit_mode and checkbox %}
<input type="checkbox" name="file_list" value="{{ a.id }}">
{% endif %} #}
</a>
{% endmacro %}
{% block content %} {% block content %}
<main> <main>
@ -46,22 +24,18 @@
<div class="albums"> <div class="albums">
{% for a in latest %} {% for a in latest %}
{{ display_album(a) }} {{ display_album(a, edit_mode=False) }}
{% endfor %} {% endfor %}
</div> </div>
<br> <br>
{% if edit_mode %} {% if is_sas_admin %}
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="navbar"> <div class="navbar">
<h4>{% trans %}All categories{% endtrans %}</h4> <h4>{% trans %}All categories{% endtrans %}</h4>
{# <div class="toolbar">
<input name="delete" type="submit" value="{% trans %}Delete{% endtrans %}">
</div> #}
</div> </div>
{% if clipboard %} {% if clipboard %}
@ -81,11 +55,11 @@
<div class="albums"> <div class="albums">
{% for a in categories %} {% for a in categories %}
{{ display_album(a, true) }} {{ display_album(a, edit_mode=False) }}
{% endfor %} {% endfor %}
</div> </div>
{% if edit_mode %} {% if is_sas_admin %}
</form> </form>
<br> <br>

View File

@ -4,36 +4,11 @@
<link rel="stylesheet" href="{{ scss('sas/picture.scss') }}"> <link rel="stylesheet" href="{{ scss('sas/picture.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{%- endblock -%}
{% block head %}
{{ super() }}
{% if picture.get_previous() %}
<link
rel="preload"
as="image"
href="{{ url("sas:download_compressed", picture_id=picture.get_previous().id) }}"
>
{% endif %}
{% if picture.get_next() %}
<link rel="preload" as="image" href="{{ url("sas:download_compressed", picture_id=picture.get_next().id) }}">
{% endif %}
{% endblock %}
{% block title %} {% block title %}
{% trans %}SAS{% endtrans %} {% trans %}SAS{% endtrans %}
{% endblock %} {% endblock %}
{% macro print_path(file) %} {% from "sas/macros.jinja" import print_path %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}
{% block content %} {% block content %}
<code> <code>
@ -128,16 +103,16 @@
<div class="subsection"> <div class="subsection">
<div class="navigation"> <div class="navigation">
<div id="prev"> <div id="prev">
{% if picture.get_previous() %} {% if previous_pict %}
<a href="{{ url( 'sas:picture', picture_id=picture.get_previous().id) }}#pict"> <a href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict">
<div style="background-image: url('{{ picture.get_previous().as_picture.get_download_thumb_url() }}');"></div> <div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div>
</a> </a>
{% endif %} {% endif %}
</div> </div>
<div id="next"> <div id="next">
{% if picture.get_next() %} {% if next_pict %}
<a href="{{ url( 'sas:picture', picture_id=picture.get_next().id) }}#pict"> <a href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict">
<div style="background-image: url('{{ picture.get_next().as_picture.get_download_thumb_url() }}');"></div> <div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div>
</a> </a>
{% endif %} {% endif %}
</div> </div>
@ -145,11 +120,13 @@
<div class="tags"> <div class="tags">
<h5>{% trans %}People{% endtrans %}</h5> <h5>{% trans %}People{% endtrans %}</h5>
<form action="" method="post" enctype="multipart/form-data"> {% if user.was_subscribed %}
{% csrf_token %} <form action="" method="post" enctype="multipart/form-data">
{{ form.as_p() }} {% csrf_token %}
<input type="submit" value="{% trans %}Go{% endtrans %}" /> {{ form.as_p() }}
</form> <input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
{% endif %}
<ul x-data="user_identification"> <ul x-data="user_identification">
<template x-for="item in items" :key="item.id"> <template x-for="item in items" :key="item.id">
<li> <li>

View File

@ -44,12 +44,8 @@ class TestPictureSearch(TestSas):
self.client.force_login(self.user_b) self.client.force_login(self.user_b)
res = self.client.get(reverse("api:pictures") + f"?album_id={self.album_a.id}") res = self.client.get(reverse("api:pictures") + f"?album_id={self.album_a.id}")
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(self.album_a.children_pictures.values_list("id", flat=True))
self.album_a.children_pictures.order_by("-date").values_list( assert [i["id"] for i in res.json()["results"]] == expected
"id", flat=True
)
)
assert [i["id"] for i in res.json()] == expected
def test_filter_by_user(self): def test_filter_by_user(self):
self.client.force_login(self.user_b) self.client.force_login(self.user_b)
@ -58,11 +54,11 @@ class TestPictureSearch(TestSas):
) )
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.order_by("-picture__date").values_list( self.user_a.pictures.order_by(
"picture_id", flat=True "-picture__parent__date", "picture__date"
) ).values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()] == expected assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_multiple_user(self): def test_filter_by_multiple_user(self):
self.client.force_login(self.user_b) self.client.force_login(self.user_b)
@ -73,38 +69,53 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.union(self.user_b.pictures.all()) self.user_a.pictures.union(self.user_b.pictures.all())
.order_by("-picture__date") .order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True) .values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()] == expected assert [i["id"] for i in res.json()["results"]] == expected
def test_not_subscribed_user(self): def test_not_subscribed_user(self):
"""Test that a user that is not subscribed can only its own pictures.""" """Test that a user that never subscribed can only its own pictures."""
self.user_a.subscriptions.all().delete()
self.client.force_login(self.user_a) self.client.force_login(self.user_a)
res = self.client.get( res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_a.id}" reverse("api:pictures") + f"?users_identified={self.user_a.id}"
) )
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.order_by("-picture__date").values_list( self.user_a.pictures.order_by(
"picture_id", flat=True "-picture__parent__date", "picture__date"
) ).values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()] == expected assert [i["id"] for i in res.json()["results"]] == expected
# trying to access the pictures of someone else # trying to access the pictures of someone else mixed with owned pictures
res = self.client.get( # should return only owned pictures
reverse("api:pictures") + f"?users_identified={self.user_b.id}"
)
assert res.status_code == 403
# trying to access the pictures of someone else shouldn't success,
# even if mixed with owned pictures
res = self.client.get( res = self.client.get(
reverse("api:pictures") reverse("api:pictures")
+ f"?users_identified={self.user_a.id}&users_identified={self.user_b.id}" + f"?users_identified={self.user_a.id}&users_identified={self.user_b.id}"
) )
assert res.status_code == 403 assert res.status_code == 200
assert [i["id"] for i in res.json()["results"]] == expected
# trying to fetch everything should be the same
# as fetching its own pictures for a non-subscriber
res = self.client.get(reverse("api:pictures"))
assert res.status_code == 200
assert [i["id"] for i in res.json()["results"]] == expected
# trying to access the pictures of someone else should return only
# the ones where the non-subscribed user is identified too
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_b.id}"
)
assert res.status_code == 200
expected = list(
self.user_b.pictures.intersection(self.user_a.pictures.all())
.order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()["results"]] == expected
class TestPictureRelation(TestSas): class TestPictureRelation(TestSas):

48
sas/tests/test_model.py Normal file
View File

@ -0,0 +1,48 @@
from django.test import TestCase
from model_bakery import baker, seq
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
from sas.models import Picture
class TestPictureQuerySet(TestCase):
@classmethod
def setUpTestData(cls):
Picture.objects.all().delete()
cls.pictures = baker.make(
Picture,
is_moderated=True,
is_in_sas=True,
is_folder=False,
name=seq(""),
_quantity=10,
_bulk_create=True,
)
Picture.objects.filter(pk=cls.pictures[0].id).update(is_moderated=False)
def test_root(self):
root = baker.make(User, is_superuser=True)
pictures = list(Picture.objects.viewable_by(root))
self.assertCountEqual(pictures, self.pictures)
def test_subscriber(self):
subscriber = subscriber_user.make()
old_subcriber = old_subscriber_user.make()
for user in (subscriber, old_subcriber):
pictures = list(Picture.objects.viewable_by(user))
self.assertCountEqual(pictures, self.pictures[1:])
def test_not_subscribed_identified(self):
user = baker.make(
# This is the guy who asked the feature of making pictures
# available for tagged users, even if not subscribed
User,
first_name="Pierrick",
last_name="Dheilly",
nick_name="Sahmer",
)
user.pictures.create(picture=self.pictures[0])
user.pictures.create(picture=self.pictures[1])
pictures = list(Picture.objects.viewable_by(user))
assert pictures == [self.pictures[1]]

View File

@ -18,7 +18,6 @@ from ajax_select.fields import AutoCompleteSelectMultipleField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.paginator import InvalidPage, Paginator
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
@ -120,10 +119,11 @@ class SASMainView(FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["categories"] = Album.objects.filter( albums_qs = Album.objects.viewable_by(self.request.user)
parent__id=settings.SITH_SAS_ROOT_DIR_ID kwargs["categories"] = list(
).order_by("id") albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
kwargs["latest"] = Album.objects.filter(is_moderated=True).order_by("-id")[:5] )
kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
return kwargs return kwargs
@ -181,7 +181,14 @@ class PictureView(CanViewMixin, DetailView, FormMixin):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
pictures_qs = Picture.objects.viewable_by(self.request.user)
kwargs["form"] = self.form kwargs["form"] = self.form
kwargs["next_pict"] = (
pictures_qs.filter(id__gt=self.object.id).order_by("id").first()
)
kwargs["previous_pict"] = (
pictures_qs.filter(id__lt=self.object.id).order_by("-id").first()
)
return kwargs return kwargs
def get_success_url(self): def get_success_url(self):
@ -222,8 +229,9 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin):
parent=parent, parent=parent,
owner=request.user, owner=request.user,
files=files, files=files,
automodere=request.user.is_in_group( automodere=(
pk=settings.SITH_GROUP_SAS_ADMIN_ID request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
or request.user.is_root
), ),
) )
if self.form.is_valid(): if self.form.is_valid():
@ -236,7 +244,6 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
form_class = SASForm form_class = SASForm
pk_url_kwarg = "album_id" pk_url_kwarg = "album_id"
template_name = "sas/album.jinja" template_name = "sas/album.jinja"
paginate_by = settings.SITH_SAS_IMAGES_PER_PAGE
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
try: try:
@ -283,17 +290,15 @@ class AlbumView(CanViewMixin, DetailView, FormMixin):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["paginator"] = Paginator(
self.object.children_pictures.order_by("id"), self.paginate_by
)
try:
kwargs["pictures"] = kwargs["paginator"].page(self.asked_page)
except InvalidPage as e:
raise Http404 from e
kwargs["form"] = self.form kwargs["form"] = self.form
kwargs["clipboard"] = SithFile.objects.filter( kwargs["clipboard"] = SithFile.objects.filter(
id__in=self.request.session["clipboard"] id__in=self.request.session["clipboard"]
) )
kwargs["children_albums"] = list(
Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id)
.order_by("-date")
)
return kwargs return kwargs
@ -326,9 +331,7 @@ class ModerationView(TemplateView):
kwargs["albums_to_moderate"] = Album.objects.filter( kwargs["albums_to_moderate"] = Album.objects.filter(
is_moderated=False, is_in_sas=True, is_folder=True is_moderated=False, is_in_sas=True, is_folder=True
).order_by("id") ).order_by("id")
kwargs["pictures"] = Picture.objects.filter( kwargs["pictures"] = Picture.objects.filter(is_moderated=False)
is_moderated=False, is_in_sas=True, is_folder=False
)
kwargs["albums"] = Album.objects.filter( kwargs["albums"] = Album.objects.filter(
id__in=kwargs["pictures"].values("parent").distinct("parent") id__in=kwargs["pictures"].values("parent").distinct("parent")
) )

View File

@ -34,6 +34,7 @@ https://docs.djangoproject.com/en/1.8/ref/settings/
""" """
import binascii import binascii
import logging
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@ -98,6 +99,7 @@ INSTALLED_APPS = (
"matmat", "matmat",
"pedagogy", "pedagogy",
"galaxy", "galaxy",
"antispam",
) )
MIDDLEWARE = ( MIDDLEWARE = (
@ -204,13 +206,12 @@ SASS_PRECISION = 8
WSGI_APPLICATION = "sith.wsgi.application" WSGI_APPLICATION = "sith.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3", "NAME": BASE_DIR / "db.sqlite3",
} },
} }
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
@ -617,11 +618,14 @@ SITH_EBOUTIC_CB_ENABLED = True
SITH_EBOUTIC_ET_URL = ( SITH_EBOUTIC_ET_URL = (
"https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi" "https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
) )
SITH_EBOUTIC_PBX_SITE = "4000666" SITH_EBOUTIC_PBX_SITE = "1999888"
SITH_EBOUTIC_PBX_RANG = "42" SITH_EBOUTIC_PBX_RANG = "32"
SITH_EBOUTIC_PBX_IDENTIFIANT = "123456789" SITH_EBOUTIC_PBX_IDENTIFIANT = "2"
SITH_EBOUTIC_HMAC_KEY = binascii.unhexlify( SITH_EBOUTIC_HMAC_KEY = binascii.unhexlify(
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" "0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
"0123456789ABCDEF0123456789ABCDEF"
) )
SITH_EBOUTIC_PUB_KEY = "" SITH_EBOUTIC_PUB_KEY = ""
with open(os.path.join(os.path.dirname(__file__), "et_keys/pubkey.pem")) as f: with open(os.path.join(os.path.dirname(__file__), "et_keys/pubkey.pem")) as f:
@ -673,12 +677,16 @@ SITH_GIFT_LIST = [("AE Tee-shirt", _("AE tee-shirt"))]
SENTRY_DSN = "" SENTRY_DSN = ""
SENTRY_ENV = "production" SENTRY_ENV = "production"
TOXIC_DOMAINS_PROVIDERS = [
"https://www.stopforumspam.com/downloads/toxic_domains_whole.txt",
]
try: try:
from .settings_custom import * from .settings_custom import *
print("Custom settings imported", file=sys.stderr) logging.getLogger("django").info("Custom settings imported")
except: except:
print("Custom settings failed", file=sys.stderr) logging.getLogger("django").warning("Custom settings failed")
if DEBUG: if DEBUG:
INSTALLED_APPS += ("debug_toolbar",) INSTALLED_APPS += ("debug_toolbar",)
@ -734,7 +742,7 @@ SITH_FRONT_DEP_VERSIONS = {
"https://github.com/viralpatel/jquery.shorten/": "", "https://github.com/viralpatel/jquery.shorten/": "",
"https://github.com/getsentry/sentry-javascript/": "4.0.6", "https://github.com/getsentry/sentry-javascript/": "4.0.6",
"https://github.com/jhuckaby/webcamjs/": "1.0.0", "https://github.com/jhuckaby/webcamjs/": "1.0.0",
"https://github.com/alpinejs/alpine": "3.10.5", "https://github.com/alpinejs/alpine": "3.14.1",
"https://github.com/mrdoob/three.js/": "r148", "https://github.com/mrdoob/three.js/": "r148",
"https://github.com/vasturiano/three-spritetext": "1.6.5", "https://github.com/vasturiano/three-spritetext": "1.6.5",
"https://github.com/vasturiano/3d-force-graph/": "1.70.19", "https://github.com/vasturiano/3d-force-graph/": "1.70.19",