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})