3 Commits
master ... rgpd

Author SHA1 Message Date
imperosol
f08e343e17 feat: whitelist for user visibility 2026-03-14 16:43:13 +01:00
thomas girod
d374ea9651 Merge pull request #1318 from ae-utbm/vite
upgrade to vite 8
2026-03-13 09:48:42 +01:00
imperosol
10a4e71b7a upgrade to vite 8
FASTER FASTER FASTER FASTER FASTER FASTER
2026-03-13 09:46:12 +01:00
14 changed files with 807 additions and 1353 deletions

3
.gitignore vendored
View File

@@ -24,6 +24,9 @@ node_modules/
# compiled documentation # compiled documentation
site/ site/
# rollup-bundle-visualizer report
.bundle-size-report.html
### Redis ### ### Redis ###
# Ignore redis binary dump (dump.rdb) files # Ignore redis binary dump (dump.rdb) files

View File

@@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["**/static/**"] "includes": ["**/static/**", "vite.config.mts"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,

View File

@@ -244,9 +244,8 @@ class NewsListView(TemplateView):
.filter( .filter(
date_of_birth__month=localdate().month, date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day, date_of_birth__day=localdate().day,
is_viewable=True, role__in=["STUDENT", "FORMER STUDENT"],
) )
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"), .order_by("-date_of_birth"),
key=lambda u: u.date_of_birth.year, key=lambda u: u.date_of_birth.year,
) )

View File

@@ -63,6 +63,7 @@ class UserAdmin(admin.ModelAdmin):
"scrub_pict", "scrub_pict",
"user_permissions", "user_permissions",
"groups", "groups",
"whitelisted_users",
) )
inlines = (UserBanInline,) inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"] search_fields = ["first_name", "last_name", "username"]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.12 on 2026-03-14 08:39
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0048_alter_user_options")]
operations = [
migrations.AddField(
model_name="user",
name="whitelisted_users",
field=models.ManyToManyField(
help_text=(
"If this profile is hidden, "
"the users in this list will still be able to see it."
),
related_name="visible_by_whitelist",
to=settings.AUTH_USER_MODEL,
verbose_name="whitelisted users",
),
),
]

View File

@@ -131,7 +131,7 @@ class UserQuerySet(models.QuerySet):
if user.has_perm("core.view_hidden_user"): if user.has_perm("core.view_hidden_user"):
return self return self
if user.has_perm("core.view_user"): if user.has_perm("core.view_user"):
return self.filter(is_viewable=True) return self.filter(Q(is_viewable=True) | Q(whitelisted_users=user))
if user.is_anonymous: if user.is_anonymous:
return self.none() return self.none()
return self.filter(id=user.id) return self.filter(id=user.id)
@@ -279,6 +279,15 @@ class User(AbstractUser):
), ),
default=True, default=True,
) )
whitelisted_users = models.ManyToManyField(
"User",
related_name="visible_by_whitelist",
verbose_name=_("whitelisted users"),
help_text=_(
"If this profile is hidden, "
"the users in this list will still be able to see it."
),
)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
@@ -567,10 +576,31 @@ class User(AbstractUser):
return user.is_root or user.is_board_member return user.is_root or user.is_board_member
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
"""Check if the given user can be viewed by this user.
Given users A and B. A can be viewed by B if :
- A and B are the same user
- or B has the permission to view hidden users
- or B can view users in general and A didn't hide its profile
- or B is in A's whitelist.
"""
def is_in_whitelist(u: User):
if (
hasattr(self, "_prefetched_objects_cache")
and "whitelisted_users" in self._prefetched_objects_cache
):
return u in self.whitelisted_users.all()
return self.whitelisted_users.contains(u)
return ( return (
user.id == self.id user.id == self.id
or user.has_perm("core.view_hidden_user") or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable) or (
user.has_perm("core.view_user")
and (self.is_viewable or is_in_whitelist(user))
)
) )
def get_mini_item(self): def get_mini_item(self):

View File

@@ -26,7 +26,6 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card")); window.alert(gettext("Unsupported NFC card"));
}); });
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
ndef.addEventListener("reading", (event: NDEFReadingEvent) => { ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan"); this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase(); this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();

View File

@@ -399,13 +399,12 @@ class TestUserQuerySetViewableBy:
return [ return [
baker.make(User), baker.make(User),
subscriber_user.make(), subscriber_user.make(),
subscriber_user.make(is_viewable=False), *subscriber_user.make(is_viewable=False, _quantity=2),
] ]
def test_admin_user(self, users: list[User]): def test_admin_user(self, users: list[User]):
user = baker.make( user = baker.make(
User, User, user_permissions=[Permission.objects.get(codename="view_hidden_user")]
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
) )
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == set(users) assert set(viewable) == set(users)
@@ -418,6 +417,12 @@ class TestUserQuerySetViewableBy:
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]} assert set(viewable) == {users[0], users[1]}
def test_whitelist(self, users: list[User]):
user = subscriber_user.make()
users[3].whitelisted_users.add(user)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1], users[3]}
@pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser]) @pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser])
def test_not_subscriber(self, users: list[User], user_factory): def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory() user = user_factory()

View File

@@ -69,7 +69,6 @@ from core.views import (
UserCreationView, UserCreationView,
UserGodfathersTreeView, UserGodfathersTreeView,
UserGodfathersView, UserGodfathersView,
UserListView,
UserMeRedirect, UserMeRedirect,
UserMiniView, UserMiniView,
UserPreferencesView, UserPreferencesView,
@@ -136,7 +135,6 @@ urlpatterns = [
"group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail" "group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail"
), ),
# User views # User views
path("user/", UserListView.as_view(), name="user_list"),
path( path(
"user/me/<path:remaining_path>/", "user/me/<path:remaining_path>/",
UserMeRedirect.as_view(), UserMeRedirect.as_view(),

View File

@@ -48,7 +48,6 @@ from django.views.generic import (
CreateView, CreateView,
DeleteView, DeleteView,
DetailView, DetailView,
ListView,
RedirectView, RedirectView,
TemplateView, TemplateView,
) )
@@ -404,13 +403,6 @@ class UserMiniView(CanViewMixin, DetailView):
template_name = "core/user_mini.jinja" template_name = "core/user_mini.jinja"
class UserListView(ListView, CanEditPropMixin):
"""Displays the user list."""
model = User
template_name = "core/user_list.jinja"
# FIXME: the edit_once fields aren't displayed to the user (as expected). # FIXME: the edit_once fields aren't displayed to the user (as expected).
# However, if the user re-add them manually in the form, they are saved. # However, if the user re-add them manually in the form, they are saved.
class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView): class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):

View File

@@ -146,7 +146,7 @@
<label for="{{ input_id }}"> <label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.is_viewable %} {%- if user.can_view(candidature.user) %}
{% if candidature.user.profile_pict %} {% if candidature.user.profile_pict %}
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}"> <img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
{% else %} {% else %}

2047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,6 @@
"compile-dev": "vite build --mode development", "compile-dev": "vite build --mode development",
"serve": "vite build --mode development --watch --minify false", "serve": "vite build --mode development --watch --minify false",
"openapi": "openapi-ts", "openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "tsc && biome check --write" "check": "tsc && biome check --write"
}, },
"keywords": [], "keywords": [],
@@ -35,10 +33,9 @@
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^8.0.0"
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.2.0"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.15.8", "@alpinejs/sort": "^3.15.8",

View File

@@ -1,14 +1,17 @@
// biome-ignore lint/correctness/noNodejsModules: this is backend side
import { parse, resolve } from "node:path"; import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject"; import inject from "@rollup/plugin-inject";
import { glob } from "glob"; import { glob } from "glob";
import type { Rollup } from "vite"; import { visualizer } from "rollup-plugin-visualizer";
import { type AliasOptions, defineConfig, type UserConfig } from "vite"; import {
type AliasOptions,
defineConfig,
type PluginOption,
type Rollup,
type UserConfig,
} from "vite";
import tsconfig from "./tsconfig.json"; import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled"); const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
const vendored = resolve(outDir, "vendored");
const nodeModules = resolve(__dirname, "node_modules");
const collectedFiles = glob.sync( const collectedFiles = glob.sync(
"./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)", "./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)",
); );
@@ -42,7 +45,6 @@ function getRelativeAssetPath(path: string): string {
return relativePath.join("/"); return relativePath.join("/");
} }
// biome-ignore lint/style/noDefaultExport: this is recommended by documentation
export default defineConfig((config: UserConfig) => { export default defineConfig((config: UserConfig) => {
return { return {
base: "/static/bundled/", base: "/static/bundled/",
@@ -86,6 +88,7 @@ export default defineConfig((config: UserConfig) => {
Alpine: "alpinejs", Alpine: "alpinejs",
htmx: "htmx.org", htmx: "htmx.org",
}), }),
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
], ],
} satisfies UserConfig; } satisfies UserConfig;
}); });