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

rafalp / Misago / 17497187987

05 Sep 2025 03:14PM UTC coverage: 96.509% (-0.3%) from 96.769%
17497187987

push

github

web-flow
Move `Post` model from `misago.threads` to `misago.posts` (#1995)

1884 of 1974 new or added lines in 149 files covered. (95.44%)

305 existing lines in 16 files now uncovered.

66716 of 69129 relevant lines covered (96.51%)

0.97 hits per line

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

92.35
/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 ...threads.synchronize import synchronize_thread
1✔
19
from ..bans import get_user_ban
1✔
20
from ..datadownloads import request_user_data_download, user_has_data_download_request
1✔
21
from ..deletesrecord import record_user_deleted_by_staff
1✔
22
from ..online.utils import get_user_status
1✔
23
from ..permissions import (
1✔
24
    allow_browse_users_list,
25
    allow_delete_user,
26
    allow_edit_profile_details,
27
    allow_follow_user,
28
    allow_moderate_avatar,
29
    allow_rename_user,
30
    allow_see_ban_details,
31
)
32
from ..profilefields import profilefields, serialize_profilefields_data
1✔
33
from ..serializers import BanDetailsSerializer, UserSerializer
1✔
34
from ..viewmodels import Followers, Follows, UserPosts, UserThreads
1✔
35
from .rest_permissions import BasePermission, UnbannedAnonOnly
1✔
36
from .userendpoints.avatar import avatar_endpoint, moderate_avatar_endpoint
1✔
37
from .userendpoints.create import create_endpoint
1✔
38
from .userendpoints.editdetails import edit_details_endpoint
1✔
39
from .userendpoints.list import list_endpoint
1✔
40
from .userendpoints.username import moderate_username_endpoint, username_endpoint
1✔
41

42
User = get_user_model()
1✔
43

44

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

53

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

62

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

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

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

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

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

92
        return create_endpoint(request)
1✔
93

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

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

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

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

108
        return Response(profile_json)
1✔
109

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

119
        return avatar_endpoint(request)
1✔
120

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

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

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

143
        profile_followers = profile.followers
1✔
144

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

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

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

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

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

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

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

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

186
        return moderate_avatar_endpoint(request, profile)
1✔
187

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

198
        return moderate_username_endpoint(request, profile)
1✔
199

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

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

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

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

221
                    posts = profile.post_set.select_related(
1✔
222
                        "category", "thread", "thread__category"
223
                    ).filter(is_hidden=False)
224
                    for post in posts.iterator(chunk_size=50):
1✔
225
                        categories_to_sync.add(post.category_id)
×
226
                        hide_post(request.user, post)
×
NEW
227
                        synchronize_thread(post.thread)
×
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✔
269
        profile = self.get_user(request, pk)
×
270
        start = get_int_or_404(request.query_params.get("start", 0))
×
271
        feed = UserThreads(request, profile, start)
×
272
        return Response(feed.get_frontend_context())
×
273

274
    @action(methods=["get"], detail=True)
1✔
275
    def posts(self, request, pk=None):
1✔
276
        profile = self.get_user(request, pk)
×
277
        start = get_int_or_404(request.query_params.get("start", 0))
×
278
        feed = UserPosts(request, profile, start)
×
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