13 Commits

Author SHA1 Message Date
thomas girod
0a4d21611e Merge pull request #1255 from ae-utbm/taiste
Refactors, better `PageRev` handling, better user invisibilisation and fixes
2025-11-19 14:02:59 +01:00
thomas girod
7373e3d9de Merge pull request #1254 from ae-utbm/refactor/page-merge
refactor detection of the need to merge `PageRev`
2025-11-19 13:52:52 +01:00
imperosol
3f4a41ba42 refactor detection of the need to merge PageRev 2025-11-19 13:51:38 +01:00
thomas girod
449abbb17e Merge pull request #1248 from ae-utbm/fix/api-barman-auth
fix: user search for anonymous sessions with logged barmen
2025-11-19 13:05:16 +01:00
thomas girod
9862e763ad Merge pull request #1249 from ae-utbm/membership-set-old
prevent csrf on `MembershipSetOldView`
2025-11-19 13:04:51 +01:00
imperosol
32e1f09d46 prevent csrf on MembershipSetOldView 2025-11-16 15:05:10 +01:00
imperosol
f359fab6b4 style: class for <a>-like form submit buttons 2025-11-16 15:04:30 +01:00
imperosol
0b53db7a95 fix: user search for anonymous sessions with logged barmen
Quand une session n'était pas connectée en tant qu'utilisateur, mais avait des utilisateurs connectés en tant que barman, la route de recherche des utilisateurs était 401
2025-11-16 13:31:48 +01:00
imperosol
d325b19383 typo in Sha512ApiKeyHasher docstring 2025-11-16 13:30:17 +01:00
imperosol
33cc9588b0 remove unused Mock 2025-11-16 13:12:58 +01:00
thomas girod
992b6d6b79 Merge pull request #1238 from ae-utbm/taiste
Sith theme, `Selling.date` index, galaxy simplification, OG tags, dependencies update, bugfixes and others
2025-11-10 13:19:43 +01:00
Kenneth Soares
710b4aa942 Merge pull request #1213 from ae-utbm/taiste
HTMX, Alpine, Invoice Calls, Products, Bugfixes, Other
2025-10-18 17:29:15 +02:00
Kenneth Soares
5fee2e4720 Merge pull request #1180 from ae-utbm/taiste
Com, Subscriptions, Posters, Others
2025-09-19 21:31:28 +02:00
15 changed files with 173 additions and 94 deletions

View File

@@ -8,7 +8,7 @@ from django.utils.crypto import constant_time_compare
class Sha512ApiKeyHasher(BasePasswordHasher): class Sha512ApiKeyHasher(BasePasswordHasher):
""" """
An API key hasher using the sha256 algorithm. An API key hasher using the sha512 algorithm.
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting. This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
It is insecure for use in hashing passwords, but is safe for hashing It is insecure for use in hashing passwords, but is safe for hashing

View File

@@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Max from django.db.models import Max
from django.test import TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from model_bakery import baker from model_bakery import baker
@@ -532,6 +532,35 @@ class TestMembership(TestClub):
assert new_board == initial_board assert new_board == initial_board
@pytest.mark.django_db
def test_membership_set_old(client: Client):
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
client.force_login(membership.user)
response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
membership.refresh_from_db()
assert membership.end_date == localdate()
@pytest.mark.django_db
def test_membership_delete(client: Client):
user = baker.make(User, is_superuser=True)
membership = baker.make(Membership)
client.force_login(user)
url = reverse("club:membership_delete", kwargs={"membership_id": membership.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(url)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
assert not Membership.objects.filter(id=membership.id).exists()
@pytest.mark.django_db @pytest.mark.django_db
class TestJoinClub: class TestJoinClub:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@@ -34,7 +34,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum from django.db.models import F, Q, Sum
from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse from django.http import Http404, StreamingHttpResponse
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
from django.utils import timezone from django.utils import timezone
@@ -43,6 +43,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import ( from club.forms import (
@@ -544,33 +545,17 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
permission_required = "club.add_club" permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, DetailView): class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as beeing old.""" """Set a membership as being old."""
model = Membership model = Membership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
def get(self, request, *args, **kwargs): def post(self, *_args, **_kwargs):
self.object = self.get_object() self.object = self.get_object()
self.object.end_date = timezone.now() self.object.end_date = timezone.now()
self.object.save() self.object.save()
return HttpResponseRedirect( return redirect("core:user_clubs", user_id=self.object.user_id)
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
class MembershipDeleteView(PermissionRequiredMixin, DeleteView): class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
@@ -582,7 +567,7 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
permission_required = "club.delete_membership" permission_required = "club.delete_membership"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id}) return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user_id})
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView): class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):

View File

@@ -1,4 +1,3 @@
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@@ -18,16 +17,6 @@ from core.markdown import markdown
from core.models import User from core.models import User
@dataclass
class MockResponse:
ok: bool
value: str
@property
def content(self):
return self.value.encode("utf8")
def accel_redirect_to_file(response: HttpResponse) -> Path | None: def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", "")) redirect = Path(response.headers.get("X-Accel-Redirect", ""))
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem): if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
import annotated_types from annotated_types import Ge, Le, MinLen
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.http import HttpResponse from django.http import HttpResponse
@@ -28,6 +28,7 @@ from core.schemas import (
UserSchema, UserSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@api_controller("/markdown") @api_controller("/markdown")
@@ -72,7 +73,7 @@ class MailingListController(ControllerBase):
@api_controller("/user") @api_controller("/user")
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) @route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks) return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks)
@@ -85,15 +86,18 @@ class UserController(ControllerBase):
"/search", "/search",
response=PaginatedResponseSchema[UserProfileSchema], response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users", url_name="search_users",
permissions=[CanAccessLookup], # logged in barmen aren't authenticated stricto sensu, so no auth here
auth=None,
) )
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter( qs = User.objects
User.objects.viewable_by(self.context.request.user).order_by( # the logged in barmen can see all users (even the hidden one),
F("last_login").desc(nulls_last=True) # because they have a temporary administrative function during
) # which they may have to deal with hidden users
) if not is_logged_in_counter(self.context.request):
qs = qs.viewable_by(self.context.request.user)
return filters.filter(qs.order_by(F("last_login").desc(nulls_last=True)))
@api_controller("/file") @api_controller("/file")
@@ -105,7 +109,7 @@ class SithFileController(ControllerBase):
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]): def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search) return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@@ -118,11 +122,11 @@ class GroupController(ControllerBase):
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]): def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).values() return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)] DepthValue = Annotated[int, Ge(0), Le(10)]
DEFAULT_DEPTH = 4 DEFAULT_DEPTH = 4

View File

@@ -23,12 +23,13 @@
# #
from __future__ import annotations from __future__ import annotations
import difflib
import string import string
import unicodedata import unicodedata
from datetime import timedelta from datetime import timedelta
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Self from typing import TYPE_CHECKING, Final, Self
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
@@ -1344,6 +1345,9 @@ class PageRev(models.Model):
The content is in PageRev.title and PageRev.content . The content is in PageRev.title and PageRev.content .
""" """
MERGE_TIME_THRESHOLD: Final[timedelta] = timedelta(minutes=20)
MERGE_DIFF_THRESHOLD: Final[float] = 0.2
revision = models.IntegerField(_("revision")) revision = models.IntegerField(_("revision"))
title = models.CharField(_("page title"), max_length=255, blank=True) title = models.CharField(_("page title"), max_length=255, blank=True)
content = models.TextField(_("page content"), blank=True) content = models.TextField(_("page content"), blank=True)
@@ -1385,6 +1389,32 @@ class PageRev(models.Model):
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups) return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.
The result is a float in [0; 1], 0 meaning the contents are entirely different,
and 1 they are strictly the same.
"""
# cf. https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
return difflib.SequenceMatcher(None, self.content, text).quick_ratio()
def should_merge(self, other: Self) -> bool:
"""Return True if `other` should be merged into `self`, else False.
It's considered the other revision should be merged into this one if :
- it was made less than 20 minutes after
- by the same author
- with a similarity ratio higher than 80%
"""
return (
not self._state.adding # cannot merge if the original rev doesn't exist
and self.author == other.author
and (other.date - self.date) < self.MERGE_TIME_THRESHOLD
and (not other._state.adding or other.revision == self.revision + 1)
and self.similarity_ratio(other.content) >= (1 - other.MERGE_DIFF_THRESHOLD)
)
def get_notification_types(): def get_notification_types():
return settings.SITH_NOTIFICATIONS return settings.SITH_NOTIFICATIONS

View File

@@ -21,6 +21,8 @@ $secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
$white-color: hsl(219.6, 20.8%, 98%); $white-color: hsl(219.6, 20.8%, 98%);
$black-color: hsl(0, 0%, 17%); $black-color: hsl(0, 0%, 17%);
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
$faceblue: hsl(221, 44%, 41%); $faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%); $twitblue: hsl(206, 82%, 63%);

View File

@@ -745,4 +745,32 @@ form {
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: var(--nf-input-size); background-size: var(--nf-input-size);
} }
&.no-margin {
margin:0;
}
// a submit input that should look like a regular <a>
input[type="submit"], button {
&.link-like {
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&.link-red {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
}
font-weight: normal;
font-size: 100%;
margin: auto;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
} }

View File

@@ -5,9 +5,6 @@ $text-color: white;
$background-color-hovered: #283747; $background-color-hovered: #283747;
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
.header { .header {
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: $deepblue;
@@ -251,12 +248,15 @@ $hovered-red-text-color: #ff4d4d;
justify-content: flex-start; justify-content: flex-start;
} }
a {
color: $text-color;
}
a, a,
button { button {
font-size: 100%; font-size: 100%;
margin: 0; margin: 0;
text-align: right; text-align: right;
color: $text-color;
margin-top: auto; margin-top: auto;
&:hover { &:hover {
@@ -268,19 +268,6 @@ $hovered-red-text-color: #ff4d4d;
margin: 0; margin: 0;
display: inline; display: inline;
} }
#logout-form button {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
background: none;
border: none;
cursor: pointer;
padding: 0;
}
} }
} }
} }

View File

@@ -519,7 +519,6 @@ th {
td { td {
margin: 5px; margin: 5px;
border-collapse: collapse; border-collapse: collapse;
vertical-align: top;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View File

@@ -61,7 +61,9 @@
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a> <a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<form id="logout-form" method="post" action="{{ url("core:logout") }}"> <form id="logout-form" method="post" action="{{ url("core:logout") }}">
{% csrf_token %} {% csrf_token %}
<button type="submit">{% trans %}Logout{% endtrans %}</button> <button type="submit" class="link-like link-red">
{% trans %}Logout{% endtrans %}
</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,9 @@
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td> <td>{% trans %}Since{% endtrans %}</td>
<td></td> <td></td>
<td></td> {% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -28,7 +30,16 @@
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %} {% if m.can_be_edited_by(user) %}
<td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td> <td>
<form
method="post"
action="{{ url('club:membership_set_old', membership_id=m.id) }}"
class="no-margin"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Mark as old{% endtrans %}" />
</form>
</td>
{% endif %} {% endif %}
{% if user.has_perm("club.delete_membership") %} {% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td> <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
@@ -48,7 +59,9 @@
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td> <td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td> <td>{% trans %}To{% endtrans %}</td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
from unittest import mock
import pytest import pytest
from django.conf import settings from django.conf import settings
@@ -23,6 +24,7 @@ from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling from counter.models import Counter, Customer, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
@@ -60,7 +62,9 @@ class TestSearchUsersAPI(TestSearchUsers):
"""Test that users are ordered by last login date.""" """Test that users are ordered by last login date."""
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("api:search_users") + "?search=First") response = self.client.get(
reverse("api:search_users", query={"search": "First"})
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["count"] == 11 assert response.json()["count"] == 11
# The users are ordered by last login date, so we need to reverse the list # The users are ordered by last login date, so we need to reverse the list
@@ -69,7 +73,7 @@ class TestSearchUsersAPI(TestSearchUsers):
] ]
def test_search_case_insensitive(self): def test_search_case_insensitive(self):
"""Test that the search is case insensitive.""" """Test that the search is case-insensitive."""
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
expected = [u.id for u in self.users[::-1]] expected = [u.id for u in self.users[::-1]]
@@ -82,14 +86,19 @@ class TestSearchUsersAPI(TestSearchUsers):
assert [r["id"] for r in response.json()["results"]] == expected assert [r["id"] for r in response.json()["results"]] == expected
def test_search_nick_name(self): def test_search_nick_name(self):
"""Test that the search can be done on the nick name.""" """Test that the search can be done on the nickname."""
# hidden users should not be in the final result,
# even when the nickname matches
self.users[10].is_viewable = False
self.users[10].save()
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
# this should return users with nicknames Nick11, Nick10 and Nick1 # this should return users with nicknames Nick11, Nick10 and Nick1
response = self.client.get(reverse("api:search_users") + "?search=Nick1") response = self.client.get(
reverse("api:search_users", query={"search": "Nick1"})
)
assert response.status_code == 200 assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [ assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id, self.users[9].id,
self.users[0].id, self.users[0].id,
] ]
@@ -101,10 +110,25 @@ class TestSearchUsersAPI(TestSearchUsers):
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
# this should return users with first names First1 and First10 # this should return users with first names First1 and First10
response = self.client.get(reverse("api:search_users") + "?search=bél") response = self.client.get(reverse("api:search_users", query={"search": "bél"}))
assert response.status_code == 200 assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [belix.id] assert [r["id"] for r in response.json()["results"]] == [belix.id]
@mock.create_autospec(is_logged_in_counter, return_value=True)
def test_search_as_barman(self):
# barmen should also see hidden users
self.users[10].is_viewable = False
self.users[10].save()
response = self.client.get(
reverse("api:search_users", query={"search": "Nick1"})
)
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id,
self.users[0].id,
]
class TestSearchUsersView(TestSearchUsers): class TestSearchUsersView(TestSearchUsers):
"""Test the search user view (`GET /search`).""" """Test the search user view (`GET /search`)."""

View File

@@ -20,9 +20,9 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import difflib
import re import re
from datetime import date, datetime, timedelta from copy import copy
from datetime import date, datetime
from io import BytesIO from io import BytesIO
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
@@ -390,14 +390,11 @@ class PageRevisionForm(forms.ModelForm):
- less than 20 minutes ago - less than 20 minutes ago
- by the same author - by the same author
- with a diff ratio higher than 20% - with a similarity ratio higher than 80%
then the latter will be edited and the new revision won't be created. then the latter will be edited and the new revision won't be created.
""" """
TIME_THRESHOLD = timedelta(minutes=20)
DIFF_THRESHOLD = 0.2
class Meta: class Meta:
model = PageRev model = PageRev
fields = ["title", "content"] fields = ["title", "content"]
@@ -409,21 +406,11 @@ class PageRevisionForm(forms.ModelForm):
super().__init__(*args, instance=instance, **kwargs) super().__init__(*args, instance=instance, **kwargs)
self.author = author self.author = author
self.page = page self.page = page
self.initial_content = instance.content if instance else "" self.initial_obj: PageRev = copy(self.instance)
def diff_ratio(self, new_str: str) -> float:
return difflib.SequenceMatcher(
None, self.initial_content, new_str
).quick_ratio()
def save(self, commit=True): # noqa FBT002 def save(self, commit=True): # noqa FBT002
revision: PageRev = self.instance revision: PageRev = self.instance
if ( if not self.initial_obj.should_merge(self.instance):
revision._state.adding
or revision.author != self.author
or revision.date + self.TIME_THRESHOLD < now()
or self.diff_ratio(revision.content) < (1 - self.DIFF_THRESHOLD)
):
revision.author = self.author revision.author = self.author
revision.page = self.page revision.page = self.page
revision.id = None # if id is None, Django will create a new record revision.id = None # if id is None, Django will create a new record

View File

@@ -68,7 +68,7 @@ dev = [
"pre-commit>=4.3.0,<5.0.0", "pre-commit>=4.3.0,<5.0.0",
"ruff>=0.14.4,<1.0.0", "ruff>=0.14.4,<1.0.0",
"djhtml>=3.0.10,<4.0.0", "djhtml>=3.0.10,<4.0.0",
"faker>=37.12.0,<39.0.0", "faker>=37.12.0,<38.0.0",
"rjsmin>=1.2.5,<2.0.0", "rjsmin>=1.2.5,<2.0.0",
] ]
tests = [ tests = [