Skip to content

Commit 273a394

Browse files
authored
Merge pull request #559 from PROCOLLAB-github/fix/UserVacancyResponses
Расширил поле файла при выдачи откликов на вакансии;
2 parents 29fecb5 + 486acbc commit 273a394

File tree

3 files changed

+155
-18
lines changed

3 files changed

+155
-18
lines changed

users/helpers.py

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
from django.contrib.auth import get_user_model
33
from django.contrib.sites.shortcuts import get_current_site
44
from django.core.cache import cache
5+
from django.db import transaction
56
from django.urls import reverse
67
from django.utils import timezone
78
from django.utils.timezone import now
9+
from rest_framework.exceptions import ValidationError
810
from rest_framework_simplejwt.tokens import RefreshToken
911

12+
from files.models import UserFile
1013
from mailing.utils import send_mail
1114
from users.constants import PROTOCOL
1215
from users.models import UserAchievement, UserLink
@@ -54,24 +57,140 @@ def check_related_fields_update(data, pk):
5457
update_links(data.get("links"), pk)
5558

5659

60+
def _extract_file_links(raw_files) -> list[str]:
61+
"""
62+
Normalize file input payload into a list of links.
63+
Accepts either a list of strings or a list of dicts with a `link` key.
64+
"""
65+
66+
if not raw_files:
67+
return []
68+
69+
if isinstance(raw_files, str):
70+
raw_files = [raw_files]
71+
72+
if not isinstance(raw_files, (list, tuple)):
73+
return []
74+
75+
links: list[str] = []
76+
for item in raw_files:
77+
if isinstance(item, str):
78+
links.append(item)
79+
elif isinstance(item, dict):
80+
link = item.get("link")
81+
if isinstance(link, str):
82+
links.append(link)
83+
# keep original order but remove empties/duplicates
84+
seen = set()
85+
deduped = []
86+
for link in links:
87+
if link and link not in seen:
88+
seen.add(link)
89+
deduped.append(link)
90+
return deduped
91+
92+
93+
def _resolve_user_files(file_links: list[str], user_id: int) -> list[UserFile]:
94+
"""
95+
Resolve file links to UserFile objects, validating ownership.
96+
"""
97+
98+
if not file_links:
99+
return []
100+
101+
files = UserFile.objects.filter(link__in=file_links)
102+
files_by_link = {f.link: f for f in files}
103+
104+
missing = [link for link in file_links if link not in files_by_link]
105+
if missing:
106+
raise ValidationError({"achievements": [f"Файлы не найдены: {missing}"]})
107+
108+
wrong_owner = [
109+
link
110+
for link, file in files_by_link.items()
111+
if file.user_id is None or file.user_id != user_id
112+
]
113+
if wrong_owner:
114+
raise ValidationError(
115+
{
116+
"achievements": [
117+
"Нельзя привязать файлы: нет владельца или владелец другой "
118+
f"({wrong_owner})"
119+
]
120+
}
121+
)
122+
123+
# Preserve original ordering
124+
return [files_by_link[link] for link in file_links]
125+
126+
127+
@transaction.atomic
57128
def update_achievements(achievements, pk):
58129
"""
59130
Bootleg version of updating achievements via user
60131
"""
61132

62-
# delete all old achievements
63-
UserAchievement.objects.filter(user_id=pk).delete()
64-
# create new achievements
65-
UserAchievement.objects.bulk_create(
66-
[
67-
UserAchievement(
133+
if achievements is None:
134+
return
135+
136+
if not isinstance(achievements, list):
137+
raise ValidationError({"achievements": ["Должен быть списком объектов."]})
138+
139+
existing_achievements = {
140+
achievement.id: achievement
141+
for achievement in UserAchievement.objects.filter(user_id=pk)
142+
}
143+
seen_ids: set[int] = set()
144+
145+
for achievement_payload in achievements:
146+
if not isinstance(achievement_payload, dict):
147+
raise ValidationError({"achievements": ["Каждое достижение должно быть объектом."]})
148+
149+
achievement_id = achievement_payload.get("id")
150+
has_year_key = "year" in achievement_payload
151+
raw_files = None
152+
files_key_present = False
153+
154+
if "file_links" in achievement_payload:
155+
raw_files = achievement_payload.get("file_links")
156+
files_key_present = True
157+
elif "files" in achievement_payload:
158+
raw_files = achievement_payload.get("files")
159+
files_key_present = True
160+
161+
file_links = (
162+
_extract_file_links(raw_files) if files_key_present else None
163+
)
164+
165+
if achievement_id and achievement_id in existing_achievements:
166+
achievement_instance = existing_achievements[achievement_id]
167+
title = achievement_payload.get("title")
168+
status = achievement_payload.get("status")
169+
170+
if title is not None:
171+
achievement_instance.title = title
172+
if status is not None:
173+
achievement_instance.status = status
174+
if has_year_key:
175+
achievement_instance.year = achievement_payload.get("year")
176+
achievement_instance.save()
177+
else:
178+
achievement_instance = UserAchievement.objects.create(
68179
user_id=pk,
69-
title=achievement.get("title"),
70-
status=achievement.get("status"),
180+
title=achievement_payload.get("title"),
181+
status=achievement_payload.get("status"),
182+
year=achievement_payload.get("year"),
71183
)
72-
for achievement in achievements
73-
]
74-
)
184+
185+
seen_ids.add(achievement_instance.id)
186+
187+
if file_links is not None:
188+
user_files = _resolve_user_files(file_links, pk)
189+
achievement_instance.files.set(user_files)
190+
191+
stale_ids = set(existing_achievements.keys()) - seen_ids
192+
if stale_ids:
193+
UserAchievement.objects.filter(id__in=stale_ids).delete()
75194

76195

77196
def update_links(links, pk):

vacancy/serializers.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,6 @@ def create(self, validated_data):
307307
return vacancy_response
308308

309309

310-
class VacancyResponseFullFileInfoListSerializer(VacancyResponseListSerializer):
311-
"""Returns full file info."""
312-
313-
accompanying_file = UserFileSerializer(read_only=True)
314-
315-
316310
class VacancyResponseDetailSerializer(serializers.ModelSerializer[VacancyResponse]):
317311
user = UserDetailSerializer(many=False, read_only=True)
318312
vacancy = VacancyListSerializer(many=False, read_only=True)
@@ -340,3 +334,15 @@ class Meta:
340334

341335
class VacancyResponseAcceptSerializer(VacancyResponseDetailSerializer[VacancyResponse]):
342336
is_approved = serializers.BooleanField(required=True, read_only=False)
337+
338+
339+
class VacancyResponseFullFileInfoListSerializer(VacancyResponseListSerializer):
340+
"""Returns full file info."""
341+
342+
accompanying_file = UserFileSerializer(read_only=True)
343+
344+
345+
class VacancyResponseDetailReadSerializer(VacancyResponseDetailSerializer):
346+
"""Returns full file info for detail view without breaking writes."""
347+
348+
accompanying_file = UserFileSerializer(read_only=True)

vacancy/views.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
VacancyDetailSerializer,
2323
VacancyResponseAcceptSerializer,
2424
VacancyResponseDetailSerializer,
25+
VacancyResponseDetailReadSerializer,
26+
VacancyResponseFullFileInfoListSerializer,
2527
VacancyResponseListSerializer,
2628
ProjectVacancyCreateListSerializer,
2729
)
@@ -76,6 +78,11 @@ class VacancyResponseList(
7678
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
7779
serializer_class = VacancyResponseListSerializer
7880

81+
def get_serializer_class(self):
82+
if self.request.method == "GET":
83+
return VacancyResponseFullFileInfoListSerializer
84+
return super().get_serializer_class()
85+
7986
def get(self, request, *args, **kwargs):
8087
"""retrieve all responses for certain vacancy"""
8188
# note: doesn't raise an error if the vacancy_id passed is non-existent
@@ -127,6 +134,11 @@ class VacancyResponseDetail(generics.RetrieveUpdateDestroyAPIView):
127134
serializer_class = VacancyResponseDetailSerializer
128135
permission_classes = [IsVacancyResponseOwnerOrReadOnly]
129136

137+
def get_serializer_class(self):
138+
if self.request.method == "GET":
139+
return VacancyResponseDetailReadSerializer
140+
return super().get_serializer_class()
141+
130142

131143
class VacancyResponseAccept(generics.GenericAPIView):
132144
queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view()
@@ -210,7 +222,7 @@ def post(self, request, pk):
210222

211223

212224
class UserVacancyResponses(ListAPIView):
213-
serializer_class = VacancyResponseListSerializer
225+
serializer_class = VacancyResponseFullFileInfoListSerializer
214226
permission_classes = [IsVacancyResponseOwnerOrReadOnly]
215227
pagination_class = VacancyPagination
216228

0 commit comments

Comments
 (0)