feat: add community contribution ranks

This commit is contained in:
2026-09-29 22:18:57 +03:30
parent 0f6220f72c
commit 77567178ad
46 changed files with 2206 additions and 23 deletions

View File

@@ -1,9 +1,12 @@
"""Authentication-related API schemas."""
from datetime import datetime
from typing import List, Optional
from typing import List, Optional, Literal
from ninja import ModelSchema, Schema
from pydantic import Field
from apps.gamification.api.schemas import PublicGamificationBadgeSchema
from apps.users.models import User
from apps.blog.permissions import can_access_blog_admin, can_review_blog_posts, can_write_blog_posts
@@ -94,13 +97,16 @@ class UserProfileSchema(ModelSchema):
profile_picture_preview_url: Optional[str] = None
student_id: Optional[str] = None
major: Optional[str] = None
major_code: Optional[str] = None
university: Optional[str] = None
university_code: Optional[str] = None
mobile: Optional[str] = None
requires_mobile_verification: bool
has_google_link: bool
can_access_blog_admin: bool
can_write_blog_posts: bool
can_review_blog_posts: bool
show_public_profile_details: bool = False
class Meta:
model = User
@@ -119,6 +125,7 @@ class UserProfileSchema(ModelSchema):
"date_joined",
"is_email_verified",
"is_mobile_verified",
"show_public_profile_details",
"is_active",
"is_staff",
"is_superuser",
@@ -130,10 +137,18 @@ class UserProfileSchema(ModelSchema):
def resolve_major(obj):
return obj.get_major_display()
@staticmethod
def resolve_major_code(obj):
return obj.major.code if obj.major_id else None
@staticmethod
def resolve_university(obj):
return obj.get_university_display()
@staticmethod
def resolve_university_code(obj):
return obj.university.code if obj.university_id else None
@staticmethod
def resolve_requires_mobile_verification(obj):
return obj.requires_mobile_verification
@@ -284,14 +299,42 @@ class UserAuthorizationUpdateSchema(Schema):
class UserUpdateSchema(Schema):
username: Optional[str] = None
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
bio: Optional[str] = None
bio: Optional[str] = Field(default=None, max_length=500)
year_of_study: Optional[int] = None
major: Optional[str] = None
university: Optional[str] = None
student_id: Optional[str] = None
show_public_profile_details: Optional[bool] = None
class PublicProfileEventSchema(Schema):
title: str
slug: str
start_time: datetime
image_url: Optional[str] = None
participation: Literal["confirmed", "attended"]
class PublicMemberProfileSchema(Schema):
username: str
first_name: str
last_name: str
full_name: str
profile_picture: Optional[str] = None
bio: str = ""
details_visible: bool
mobile: Optional[str] = None
email: Optional[str] = None
student_id: Optional[str] = None
year_of_study: Optional[int] = None
university: Optional[str] = None
major: Optional[str] = None
badges: List[PublicGamificationBadgeSchema] = []
events: List[PublicProfileEventSchema] = []
class TokenSchema(Schema):

View File

@@ -2,15 +2,19 @@ from __future__ import annotations
import jwt
import uuid
import re
from django.conf import settings
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from ninja import Query, Router
from apps.events.models import Event, Registration
from apps.gamification.services import public_badges_for_user
from apps.users.api.schemas import (
AuthorizationRoleSchema,
GoogleClaimVerifySchema,
@@ -32,12 +36,13 @@ from apps.users.api.schemas import (
UserLoginSchema,
UserOtpLoginSchema,
UserProfileSchema,
PublicMemberProfileSchema,
UserRegistrationSchema,
UserUpdateSchema,
UsernameCheckSchema,
)
from apps.blog.permissions import ASSOCIATION_ADMIN_GROUP, BLOG_EDITOR_GROUP, BLOG_SUPERVISOR_GROUP
from apps.users.email_identity import normalize_email_identity
from apps.users.email_identity import is_placeholder_email, normalize_email_identity
from apps.users.models import Major, University, User
from apps.users.services.auth import (
AuthServiceError,
@@ -81,6 +86,9 @@ CURATED_ROLE_GROUPS = {
ASSOCIATION_ADMIN_GROUP,
}
PUBLIC_USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
PUBLIC_USERNAME_MIN_LENGTH = 5
ROLE_SPECS = [
{
"key": BLOG_EDITOR_GROUP,
@@ -374,11 +382,83 @@ def get_profile(request):
return request.auth
@auth_router.get("/public-profile/{username}", response={200: PublicMemberProfileSchema, 404: ErrorSchema})
def get_public_profile(request, username: str):
user = User.objects.filter(username=username, is_active=True, is_deleted=False).select_related(
"university", "major"
).first()
if not user:
return 404, {"error": "پروفایل پیدا نشد."}
details_visible = user.show_public_profile_details
profile_picture = None
if user.profile_picture and user.profile_picture.name:
profile_picture = request.build_absolute_uri(user.profile_picture.url)
events = []
registrations = (
Registration.objects.filter(
user=user,
event__is_deleted=False,
event__status__in=[Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED],
status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED],
)
.select_related("event")
.order_by("-event__start_time")[:12]
)
for registration in registrations:
event = registration.event
events.append({
"title": event.title,
"slug": event.slug,
"start_time": event.start_time,
"image_url": request.build_absolute_uri(event.featured_image.url) if event.featured_image else None,
"participation": registration.status,
})
profile = {
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"full_name": user.get_full_name() or user.username,
"profile_picture": profile_picture,
"bio": user.bio or "",
"details_visible": details_visible,
"mobile": user.mobile if details_visible else None,
"student_id": user.student_id if details_visible else None,
"year_of_study": user.year_of_study if details_visible else None,
"university": user.get_university_display() if details_visible else None,
"major": user.get_major_display() if details_visible else None,
"badges": public_badges_for_user(user),
"events": events,
}
if details_visible and user.email and not is_placeholder_email(user.email):
profile["email"] = user.email
return 200, profile
@auth_router.put("/profile", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
def update_profile(request, data: UserUpdateSchema):
user = request.auth
payload = data.dict(exclude_unset=True)
if "username" in payload:
username = (payload["username"] or "").strip()
username_field = User._meta.get_field("username")
if not username or len(username) > username_field.max_length:
return 400, {"error": "نام کاربری باید بین ۱ تا ۱۵۰ نویسه باشد."}
if username != user.username and (
len(username) < PUBLIC_USERNAME_MIN_LENGTH or not PUBLIC_USERNAME_PATTERN.fullmatch(username)
):
return 400, {"error": "نام کاربری جدید باید حداقل ۵ نویسه و فقط شامل حروف انگلیسی، عدد و زیرخط باشد."}
try:
username_field.run_validators(username)
except ValidationError:
return 400, {"error": "نام کاربری فقط می‌تواند شامل حروف، عدد و نویسه‌های مجاز باشد."}
if User.objects.filter(username__iexact=username).exclude(pk=user.pk).exists():
return 400, {"error": "این نام کاربری قبلاً استفاده شده است."}
payload["username"] = username
if "major" in payload:
code = payload.pop("major")
if code:
@@ -565,7 +645,7 @@ def update_user_authorization(request, user_id: int, data: UserAuthorizationUpda
@auth_router.get("/check-username", response=UsernameCheckSchema)
def check_username_availability(request, username: str):
return {"exists": User.objects.filter(username=username).exists()}
return {"exists": User.objects.filter(username__iexact=username).exists()}
@auth_router.get("/check-mobile", response={200: MobileLookupSchema, 400: ErrorSchema})

View File

@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0009_alter_user_options_and_more"),
]
operations = [
migrations.AddField(
model_name="user",
name="show_public_profile_details",
field=models.BooleanField(default=False),
),
]

View File

@@ -76,6 +76,7 @@ class User(AbstractUser, BaseModel):
)
is_email_verified = models.BooleanField(default=False)
is_mobile_verified = models.BooleanField(default=False)
show_public_profile_details = models.BooleanField(default=False)
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['mobile']

View File

@@ -77,6 +77,74 @@ def test_profile_and_authorization_roles(client, user_factory, auth_headers):
assert client.get("/api/auth/check-mobile", {"mobile": member.mobile}).json()["exists"]
def test_public_profile_hides_personal_fields_until_the_user_opts_in(client, user_factory, auth_headers, event_factory):
from apps.events.models import Event, Registration
member = user_factory(
username="public_member",
first_name="Public",
last_name="Member",
email="public.member@example.org",
mobile="09195550123",
student_id="402123456",
year_of_study=1402,
bio="A public introduction",
)
event = event_factory(slug="public-profile-event")
Registration.objects.create(event=event, user=member, status=Registration.StatusChoices.CONFIRMED)
draft_event = event_factory(slug="private-profile-event", status=Event.StatusChoices.DRAFT)
Registration.objects.create(event=draft_event, user=member, status=Registration.StatusChoices.ATTENDED)
public_path = f"/api/auth/public-profile/{member.username}"
hidden_response = client.get(public_path)
assert hidden_response.status_code == 200
hidden = hidden_response.json()
assert hidden["full_name"] == "Public Member"
assert hidden["details_visible"] is False
assert hidden["mobile"] is None
assert hidden["student_id"] is None
assert hidden["year_of_study"] is None
assert "date_joined" not in hidden
assert "blog_activity" not in hidden
assert [row["slug"] for row in hidden["events"]] == [event.slug]
assert hidden.get("email") is None
updated = client.put(
"/api/auth/profile",
data=json.dumps({"show_public_profile_details": True}),
content_type="application/json",
**auth_headers(member),
)
assert updated.status_code == 200, updated.content
visible = client.get(public_path).json()
assert visible["details_visible"] is True
assert visible["mobile"] == member.mobile
assert visible["student_id"] == member.student_id
assert visible["year_of_study"] == member.year_of_study
assert visible["email"] == member.email
assert "date_joined" not in visible
assert "blog_activity" not in visible
def test_public_profile_does_not_expose_placeholder_email(client, user_factory):
member = user_factory(
username="public_placeholder_email",
email="pending-public-placeholder@noemail.local",
show_public_profile_details=True,
)
response = client.get(f"/api/auth/public-profile/{member.username}")
assert response.status_code == 200
assert response.json().get("email") is None
def test_public_profile_does_not_expose_inactive_users(client, user_factory):
member = user_factory(username="inactive_public_member", is_active=False)
response = client.get(f"/api/auth/public-profile/{member.username}")
assert response.status_code == 404
def test_profile_picture_upload_validation_and_delete(client, user_factory, auth_headers, settings, tmp_path):
settings.MEDIA_ROOT = tmp_path
user = user_factory()
@@ -150,6 +218,8 @@ def test_profile_rejects_duplicate_email_and_resolves_education_codes(client, us
changed = client.put(path, data=json.dumps({"major": major.code, "university": university.code}),
content_type="application/json", **headers)
assert changed.status_code == 200, changed.content
assert changed.json()["major_code"] == major.code
assert changed.json()["university_code"] == university.code
user.refresh_from_db()
assert user.major == major and user.university == university
assert client.put(path, data=json.dumps({"major": None, "university": None}),
@@ -158,6 +228,92 @@ def test_profile_rejects_duplicate_email_and_resolves_education_codes(client, us
assert user.major is None and user.university is None
def test_profile_update_allows_username_changes_and_limits_plain_text_bio(client, user_factory, auth_headers):
member = user_factory(username="before_edit")
another = user_factory(username="already_taken")
headers = auth_headers(member)
path = "/api/auth/profile"
changed = client.put(
path,
data=json.dumps({"username": "after_edit", "bio": "A short plain text introduction"}),
content_type="application/json",
**headers,
)
assert changed.status_code == 200, changed.content
assert changed.json()["username"] == "after_edit"
duplicate = client.put(
path,
data=json.dumps({"username": another.username}),
content_type="application/json",
**headers,
)
assert duplicate.status_code == 400
too_long = client.put(
path,
data=json.dumps({"bio": "a" * 501}),
content_type="application/json",
**headers,
)
assert too_long.status_code == 422
at_limit = client.put(
path,
data=json.dumps({"bio": "a" * 500}),
content_type="application/json",
**headers,
)
assert at_limit.status_code == 200, at_limit.content
def test_profile_username_changes_follow_public_username_rules(client, user_factory, auth_headers):
member = user_factory(username="amir")
user_factory(username="already_taken")
headers = auth_headers(member)
path = "/api/auth/profile"
unchanged_legacy_name = client.put(
path,
data=json.dumps({"username": "amir"}),
content_type="application/json",
**headers,
)
too_short = client.put(
path,
data=json.dumps({"username": "abc1"}),
content_type="application/json",
**headers,
)
invalid_characters = client.put(
path,
data=json.dumps({"username": "valid-name"}),
content_type="application/json",
**headers,
)
duplicate = client.put(
path,
data=json.dumps({"username": "already_taken"}),
content_type="application/json",
**headers,
)
available = client.put(
path,
data=json.dumps({"username": "valid_name42"}),
content_type="application/json",
**headers,
)
case_insensitive_availability = client.get("/api/auth/check-username?username=ALREADY_TAKEN")
assert unchanged_legacy_name.status_code == 200
assert too_short.status_code == 400
assert invalid_characters.status_code == 400
assert duplicate.status_code == 400
assert available.status_code == 200, available.content
assert available.json()["username"] == "valid_name42"
assert case_insensitive_availability.status_code == 200
assert case_insensitive_availability.json()["exists"] is True
def test_authorization_rejects_unknown_group_and_refresh_bad_token(client, user_factory, auth_headers):
admin = user_factory(is_superuser=True, is_staff=True)
member = user_factory()