feat: add community contribution ranks
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user