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})
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user