• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rafalp / Misago / 17420091505

03 Sep 2025 01:12AM UTC coverage: 96.769% (-0.2%) from 96.953%
17420091505

push

github

web-flow
Remove misago.threads api, participants, permissions, serializers, validators (#1994)

20 of 21 new or added lines in 4 files covered. (95.24%)

400 existing lines in 42 files now uncovered.

68475 of 70761 relevant lines covered (96.77%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.8
/misago/users/api/users.py
1
from django.contrib.auth import get_user_model
1✔
2
from django.core.exceptions import PermissionDenied
1✔
3
from django.db import transaction
1✔
4
from django.db.models import F
1✔
5
from django.http import Http404
1✔
6
from django.shortcuts import get_object_or_404
1✔
7
from django.utils.translation import pgettext
1✔
8
from rest_framework import status, viewsets
1✔
9
from rest_framework.decorators import action
1✔
10
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
1✔
11
from rest_framework.response import Response
1✔
12

13
from ...acl.objectacl import add_acl_to_obj
1✔
14
from ...categories.models import Category
1✔
15
from ...core.rest_permissions import IsAuthenticatedOrReadOnly
1✔
16
from ...core.shortcuts import get_int_or_404
1✔
17
from ...threads.moderation import hide_post, hide_thread
1✔
18
from ..bans import get_user_ban
1✔
19
from ..datadownloads import request_user_data_download, user_has_data_download_request
1✔
20
from ..deletesrecord import record_user_deleted_by_staff
1✔
21
from ..online.utils import get_user_status
1✔
22
from ..permissions import (
1✔
23
    allow_browse_users_list,
24
    allow_delete_user,
25
    allow_edit_profile_details,
26
    allow_follow_user,
27
    allow_moderate_avatar,
28
    allow_rename_user,
29
    allow_see_ban_details,
30
)
31
from ..profilefields import profilefields, serialize_profilefields_data
1✔
32
from ..serializers import BanDetailsSerializer, UserSerializer
1✔
33
from ..viewmodels import Followers, Follows, UserPosts, UserThreads
1✔
34
from .rest_permissions import BasePermission, UnbannedAnonOnly
1✔
35
from .userendpoints.avatar import avatar_endpoint, moderate_avatar_endpoint
1✔
36
from .userendpoints.create import create_endpoint
1✔
37
from .userendpoints.editdetails import edit_details_endpoint
1✔
38
from .userendpoints.list import list_endpoint
1✔
39
from .userendpoints.username import moderate_username_endpoint, username_endpoint
1✔
40

41
User = get_user_model()
1✔
42

43

44
class UserViewSetPermission(BasePermission):
1✔
45
    def has_permission(self, request, view):
1✔
46
        if view.action == "create":
1✔
47
            policy = UnbannedAnonOnly()
1✔
48
        else:
49
            policy = IsAuthenticatedOrReadOnly()
1✔
50
        return policy.has_permission(request, view)
1✔
51

52

53
def allow_self_only(user, pk, message):
1✔
54
    if user.is_anonymous:
1✔
55
        raise PermissionDenied(
1✔
56
            pgettext("users api", "You have to sign in to perform this action.")
57
        )
58
    if user.pk != int(pk):
1✔
59
        raise PermissionDenied(message)
1✔
60

61

62
class UserViewSet(viewsets.GenericViewSet):
1✔
63
    permission_classes = (UserViewSetPermission,)
1✔
64
    parser_classes = (FormParser, JSONParser, MultiPartParser)
1✔
65
    queryset = User.objects
1✔
66

67
    def get_queryset(self):
1✔
68
        relations = ("rank", "online_tracker", "ban_cache")
1✔
69
        return self.queryset.select_related(*relations)
1✔
70

71
    def get_user(self, request, pk):
1✔
72
        user = get_object_or_404(self.get_queryset(), pk=get_int_or_404(pk))
1✔
73
        if not user.is_active and not request.user.is_misago_admin:
1✔
74
            raise Http404()
1✔
75
        return user
1✔
76

77
    def list(self, request):
1✔
78
        allow_browse_users_list(request.user_acl)
1✔
79
        return list_endpoint(request)
1✔
80

81
    def create(self, request):
1✔
82
        if request.settings.enable_oauth2_client:
1✔
83
            raise PermissionDenied(
1✔
84
                pgettext(
85
                    "users api",
86
                    "This feature has been disabled. Please use %(provider)s to sign in.",
87
                )
88
                % {"provider": request.settings.oauth2_provider}
89
            )
90

91
        return create_endpoint(request)
1✔
92

93
    def retrieve(self, request, pk=None):
1✔
94
        profile = self.get_user(request, pk)
1✔
95

96
        add_acl_to_obj(request.user_acl, profile)
1✔
97
        profile.status = get_user_status(request, profile)
1✔
98

99
        serializer = UserProfileSerializer(profile, context={"request": request})
1✔
100
        profile_json = serializer.data
1✔
101

102
        if not profile.is_active:
1✔
103
            profile_json["is_active"] = False
1✔
104
        if profile.is_deleting_account:
1✔
105
            profile_json["is_deleting_account"] = True
×
106

107
        return Response(profile_json)
1✔
108

109
    @action(methods=["get", "post"], detail=True)
1✔
110
    def avatar(self, request, pk=None):
1✔
111
        get_int_or_404(pk)
1✔
112
        allow_self_only(
1✔
113
            request.user,
114
            pk,
115
            pgettext("users api", "You can't change other users avatars."),
116
        )
117

118
        return avatar_endpoint(request)
1✔
119

120
    @action(methods=["get"], detail=True)
1✔
121
    def details(self, request, pk=None):
1✔
122
        profile = self.get_user(request, pk)
1✔
123
        data = serialize_profilefields_data(request, profilefields, profile)
1✔
124
        return Response(data)
1✔
125

126
    @action(
1✔
127
        methods=["get", "post"],
128
        detail=True,
129
        url_path="edit-details",
130
        url_name="edit-details",
131
    )
132
    def edit_details(self, request, pk=None):
1✔
133
        profile = self.get_user(request, pk)
1✔
134
        allow_edit_profile_details(request.user_acl, profile)
1✔
135
        return edit_details_endpoint(request, profile)
1✔
136

137
    @action(methods=["post"], detail=True)
1✔
138
    def follow(self, request, pk=None):
1✔
139
        profile = self.get_user(request, pk)
1✔
140
        allow_follow_user(request.user_acl, profile)
1✔
141

142
        profile_followers = profile.followers
1✔
143

144
        with transaction.atomic():
1✔
145
            if request.user.is_following(profile):
1✔
146
                request.user.follows.remove(profile)
1✔
147
                followed = False
1✔
148

149
                profile_followers -= 1
1✔
150
                profile.followers = F("followers") - 1
1✔
151
                request.user.following = F("following") - 1
1✔
152
            else:
153
                request.user.follows.add(profile)
1✔
154
                followed = True
1✔
155

156
                profile_followers += 1
1✔
157
                profile.followers = F("followers") + 1
1✔
158
                request.user.following = F("following") + 1
1✔
159

160
            profile.save(update_fields=["followers"])
1✔
161
            request.user.save(update_fields=["following"])
1✔
162

163
            return Response({"is_followed": followed, "followers": profile_followers})
1✔
164

165
    @action(detail=True)
1✔
166
    def ban(self, request, pk=None):
1✔
167
        profile = self.get_user(request, pk)
1✔
168
        allow_see_ban_details(request.user_acl, profile)
1✔
169

170
        ban = get_user_ban(profile, request.cache_versions)
1✔
171
        if ban:
1✔
172
            return Response(BanDetailsSerializer(ban).data)
1✔
173
        return Response({})
1✔
174

175
    @action(
1✔
176
        methods=["get", "post"],
177
        detail=True,
178
        url_path="moderate-avatar",
179
        url_name="moderate-avatar",
180
    )
181
    def moderate_avatar(self, request, pk=None):
1✔
182
        profile = self.get_user(request, pk)
1✔
183
        allow_moderate_avatar(request.user_acl, profile)
1✔
184

185
        return moderate_avatar_endpoint(request, profile)
1✔
186

187
    @action(
1✔
188
        methods=["get", "post"],
189
        detail=True,
190
        url_path="moderate-username",
191
        url_name="moderate-username",
192
    )
193
    def moderate_username(self, request, pk=None):
1✔
194
        profile = self.get_user(request, pk)
1✔
195
        allow_rename_user(request.user_acl, profile)
1✔
196

197
        return moderate_username_endpoint(request, profile)
1✔
198

199
    @action(methods=["get", "post"], detail=True)
1✔
200
    def delete(self, request, pk=None):
1✔
201
        profile = self.get_user(request, pk)
1✔
202
        allow_delete_user(request.user_acl, profile)
1✔
203

204
        if request.method == "POST":
1✔
205
            with transaction.atomic():
1✔
206
                profile.lock()
1✔
207

208
                if request.data.get("with_content"):
1✔
209
                    profile.delete_content()
1✔
210
                else:
211
                    categories_to_sync = set()
1✔
212

213
                    threads = profile.thread_set.select_related(
1✔
214
                        "category", "first_post"
215
                    ).filter(is_hidden=False)
216
                    for thread in threads.iterator(chunk_size=50):
1✔
217
                        categories_to_sync.add(thread.category_id)
1✔
218
                        hide_thread(request, thread)
1✔
219

220
                    posts = profile.post_set.select_related(
1✔
221
                        "category", "thread", "thread__category"
222
                    ).filter(is_hidden=False)
223
                    for post in posts.iterator(chunk_size=50):
1✔
224
                        categories_to_sync.add(post.category_id)
×
225
                        hide_post(request.user, post)
×
226
                        post.thread.synchronize()
×
227
                        post.thread.save()
×
228

229
                    categories = Category.objects.filter(id__in=categories_to_sync)
1✔
230
                    for category in categories.iterator():
1✔
231
                        category.synchronize()
1✔
232
                        category.save()
1✔
233

234
                profile.delete(anonymous_username=request.settings.anonymous_username)
1✔
235
                record_user_deleted_by_staff()
1✔
236

237
        return Response({})
1✔
238

239
    @action(methods=["get"], detail=True)
1✔
240
    def followers(self, request, pk=None):
1✔
241
        profile = self.get_user(request, pk)
1✔
242

243
        page = get_int_or_404(request.query_params.get("page", 0))
1✔
244
        if page == 1:
1✔
245
            page = 0  # api allows explicit first page
×
246

247
        search = request.query_params.get("search")
1✔
248

249
        users = Followers(request, profile, page, search)
1✔
250

251
        return Response(users.get_frontend_context())
1✔
252

253
    @action(methods=["get"], detail=True)
1✔
254
    def follows(self, request, pk=None):
1✔
255
        profile = self.get_user(request, pk)
1✔
256

257
        page = get_int_or_404(request.query_params.get("page", 0))
1✔
258
        if page == 1:
1✔
259
            page = 0  # api allows explicit first page
×
260

261
        search = request.query_params.get("search")
1✔
262

263
        users = Follows(request, profile, page, search)
1✔
264

265
        return Response(users.get_frontend_context())
1✔
266

267
    @action(methods=["get"], detail=True)
1✔
268
    def threads(self, request, pk=None):
1✔
UNCOV
269
        profile = self.get_user(request, pk)
×
UNCOV
270
        start = get_int_or_404(request.query_params.get("start", 0))
×
UNCOV
271
        feed = UserThreads(request, profile, start)
×
UNCOV
272
        return Response(feed.get_frontend_context())
×
273

274
    @action(methods=["get"], detail=True)
1✔
275
    def posts(self, request, pk=None):
1✔
UNCOV
276
        profile = self.get_user(request, pk)
×
UNCOV
277
        start = get_int_or_404(request.query_params.get("start", 0))
×
UNCOV
278
        feed = UserPosts(request, profile, start)
×
UNCOV
279
        return Response(feed.get_frontend_context())
×
280

281

282
UserProfileSerializer = UserSerializer.subset_fields(
1✔
283
    "id",
284
    "username",
285
    "slug",
286
    "email",
287
    "joined_on",
288
    "rank",
289
    "title",
290
    "avatars",
291
    "is_avatar_locked",
292
    "signature",
293
    "is_signature_locked",
294
    "followers",
295
    "following",
296
    "threads",
297
    "posts",
298
    "acl",
299
    "is_followed",
300
    "is_blocked",
301
    "real_name",
302
    "status",
303
    "api",
304
    "url",
305
)
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc