|
1
|
from django.contrib import messages |
|
2
|
from django.contrib.auth.decorators import login_required |
|
3
|
from django.core.paginator import Paginator |
|
4
|
from django.db.models import Count |
|
5
|
from django.http import HttpResponse |
|
6
|
from django.shortcuts import get_object_or_404, redirect, render |
|
7
|
|
|
8
|
from core.pagination import PER_PAGE_OPTIONS, get_per_page |
|
9
|
from core.permissions import P |
|
10
|
from organization.models import Team |
|
11
|
from organization.views import get_org |
|
12
|
|
|
13
|
from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm |
|
14
|
from .models import Project, ProjectGroup, ProjectStar, ProjectTeam |
|
15
|
|
|
16
|
|
|
17
|
def project_list(request): |
|
18
|
if request.user.is_authenticated: |
|
19
|
# Authenticated users with PROJECT_VIEW perm see all non-deleted projects; |
|
20
|
# those without it see only public + internal. |
|
21
|
if P.PROJECT_VIEW.check(request.user, raise_error=False): |
|
22
|
projects = Project.objects.all() |
|
23
|
else: |
|
24
|
projects = Project.objects.filter(visibility__in=[Project.Visibility.PUBLIC, Project.Visibility.INTERNAL]) |
|
25
|
else: |
|
26
|
# Anonymous users see only public projects. |
|
27
|
projects = Project.objects.filter(visibility=Project.Visibility.PUBLIC) |
|
28
|
|
|
29
|
search = request.GET.get("search", "").strip() |
|
30
|
if search: |
|
31
|
projects = projects.filter(name__icontains=search) |
|
32
|
|
|
33
|
per_page = get_per_page(request) |
|
34
|
paginator = Paginator(projects, per_page) |
|
35
|
page_obj = paginator.get_page(request.GET.get("page", 1)) |
|
36
|
|
|
37
|
ctx = {"projects": page_obj, "page_obj": page_obj, "search": search, "per_page": per_page, "per_page_options": PER_PAGE_OPTIONS} |
|
38
|
|
|
39
|
if request.headers.get("HX-Request"): |
|
40
|
return render(request, "projects/partials/project_table.html", ctx) |
|
41
|
|
|
42
|
return render(request, "projects/project_list.html", ctx) |
|
43
|
|
|
44
|
|
|
45
|
@login_required |
|
46
|
def project_create(request): |
|
47
|
P.PROJECT_ADD.check(request.user) |
|
48
|
org = get_org() |
|
49
|
|
|
50
|
if request.method == "POST": |
|
51
|
form = ProjectForm(request.POST) |
|
52
|
if form.is_valid(): |
|
53
|
project = form.save(commit=False) |
|
54
|
project.organization = org |
|
55
|
project.created_by = request.user |
|
56
|
project.save() |
|
57
|
|
|
58
|
# Handle repo source: clone from URL if requested |
|
59
|
repo_source = form.cleaned_data.get("repo_source", "empty") |
|
60
|
clone_url = form.cleaned_data.get("clone_url", "").strip() |
|
61
|
|
|
62
|
if repo_source == "fossil_url" and clone_url: |
|
63
|
from core.url_validation import is_safe_outbound_url |
|
64
|
|
|
65
|
is_safe, url_error = is_safe_outbound_url(clone_url) |
|
66
|
if not is_safe: |
|
67
|
messages.error(request, f"Invalid clone URL: {url_error}") |
|
68
|
else: |
|
69
|
_clone_fossil_repo(request, project, clone_url) |
|
70
|
|
|
71
|
messages.success(request, f'Project "{project.name}" created.') |
|
72
|
return redirect("projects:detail", slug=project.slug) |
|
73
|
else: |
|
74
|
form = ProjectForm() |
|
75
|
|
|
76
|
return render(request, "projects/project_form.html", {"form": form, "title": "New Project"}) |
|
77
|
|
|
78
|
|
|
79
|
def _clone_fossil_repo(request, project, clone_url): |
|
80
|
"""Clone a Fossil repo from a remote URL, replacing the empty file created by the signal.""" |
|
81
|
import subprocess |
|
82
|
|
|
83
|
from fossil.cli import FossilCLI |
|
84
|
from fossil.models import FossilRepository |
|
85
|
|
|
86
|
fossil_repo = FossilRepository.objects.filter(project=project).first() |
|
87
|
if not fossil_repo: |
|
88
|
return |
|
89
|
|
|
90
|
cli = FossilCLI() |
|
91
|
if not cli.is_available(): |
|
92
|
messages.warning(request, "Fossil binary not available -- clone skipped.") |
|
93
|
return |
|
94
|
|
|
95
|
# Remove the empty file created by the signal so we can clone into that path |
|
96
|
if fossil_repo.full_path.exists(): |
|
97
|
fossil_repo.full_path.unlink() |
|
98
|
|
|
99
|
try: |
|
100
|
result = subprocess.run( |
|
101
|
[cli.binary, "clone", clone_url, str(fossil_repo.full_path)], |
|
102
|
capture_output=True, |
|
103
|
text=True, |
|
104
|
timeout=120, |
|
105
|
env=cli._env, |
|
106
|
) |
|
107
|
if result.returncode == 0: |
|
108
|
fossil_repo.remote_url = clone_url |
|
109
|
fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size if fossil_repo.exists_on_disk else 0 |
|
110
|
fossil_repo.save(update_fields=["remote_url", "file_size_bytes", "updated_at", "version"]) |
|
111
|
messages.success(request, f"Repository cloned from {clone_url}") |
|
112
|
else: |
|
113
|
messages.warning(request, f"Clone failed: {result.stderr.strip()}") |
|
114
|
except subprocess.TimeoutExpired: |
|
115
|
messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.") |
|
116
|
|
|
117
|
|
|
118
|
def project_detail(request, slug): |
|
119
|
from projects.access import require_project_read |
|
120
|
|
|
121
|
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
|
122
|
require_project_read(request, project) |
|
123
|
project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team") |
|
124
|
|
|
125
|
# Get Fossil repo stats if available |
|
126
|
repo_stats = None |
|
127
|
recent_commits = [] |
|
128
|
commit_activity = [] |
|
129
|
top_contributors = [] |
|
130
|
try: |
|
131
|
from fossil.models import FossilRepository |
|
132
|
from fossil.reader import FossilReader |
|
133
|
|
|
134
|
fossil_repo = FossilRepository.objects.filter(project=project, deleted_at__isnull=True).first() |
|
135
|
if fossil_repo and fossil_repo.exists_on_disk: |
|
136
|
with FossilReader(fossil_repo.full_path) as reader: |
|
137
|
repo_stats = reader.get_metadata() |
|
138
|
recent_commits = reader.get_timeline(limit=5, event_type="ci") |
|
139
|
commit_activity = reader.get_commit_activity(weeks=52) |
|
140
|
top_contributors = reader.get_top_contributors(limit=8) |
|
141
|
except Exception: |
|
142
|
pass |
|
143
|
|
|
144
|
import json |
|
145
|
|
|
146
|
# Check if user is watching this project |
|
147
|
is_watching = False |
|
148
|
is_starred = False |
|
149
|
if request.user.is_authenticated: |
|
150
|
from fossil.notifications import ProjectWatch |
|
151
|
|
|
152
|
is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists() |
|
153
|
is_starred = ProjectStar.objects.filter(user=request.user, project=project).exists() |
|
154
|
|
|
155
|
return render( |
|
156
|
request, |
|
157
|
"projects/project_detail.html", |
|
158
|
{ |
|
159
|
"project": project, |
|
160
|
"project_teams": project_teams, |
|
161
|
"repo_stats": repo_stats, |
|
162
|
"recent_commits": recent_commits, |
|
163
|
"commit_activity_json": json.dumps([c["count"] for c in commit_activity]), |
|
164
|
"top_contributors": top_contributors, |
|
165
|
"is_watching": is_watching, |
|
166
|
"is_starred": is_starred, |
|
167
|
}, |
|
168
|
) |
|
169
|
|
|
170
|
|
|
171
|
@login_required |
|
172
|
def project_update(request, slug): |
|
173
|
P.PROJECT_CHANGE.check(request.user) |
|
174
|
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
|
175
|
|
|
176
|
if request.method == "POST": |
|
177
|
form = ProjectForm(request.POST, instance=project) |
|
178
|
if form.is_valid(): |
|
179
|
project = form.save(commit=False) |
|
180
|
project.updated_by = request.user |
|
181
|
project.save() |
|
182
|
messages.success(request, f'Project "{project.name}" updated.') |
|
183
|
return redirect("projects:detail", slug=project.slug) |
|
184
|
else: |
|
185
|
form = ProjectForm(instance=project) |
|
186
|
|
|
187
|
return render(request, "projects/project_form.html", {"form": form, "project": project, "title": "Edit Project"}) |
|
188
|
|
|
189
|
|
|
190
|
@login_required |
|
191
|
def project_delete(request, slug): |
|
192
|
P.PROJECT_DELETE.check(request.user) |
|
193
|
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
|
194
|
|
|
195
|
if request.method == "POST": |
|
196
|
project.soft_delete(user=request.user) |
|
197
|
messages.success(request, f'Project "{project.name}" deleted.') |
|
198
|
|
|
199
|
if request.headers.get("HX-Request"): |
|
200
|
return HttpResponse(status=200, headers={"HX-Redirect": "/projects/"}) |
|
201
|
|
|
202
|
return redirect("projects:list") |
|
203
|
|
|
204
|
return render(request, "projects/project_confirm_delete.html", {"project": project}) |
|
205
|
|
|
206
|
|
|
207
|
# --- Team assignment --- |
|
208
|
|
|
209
|
|
|
210
|
@login_required |
|
211
|
def project_team_add(request, slug): |
|
212
|
P.PROJECT_CHANGE.check(request.user) |
|
213
|
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
|
214
|
|
|
215
|
if request.method == "POST": |
|
216
|
form = ProjectTeamAddForm(request.POST, project=project) |
|
217
|
if form.is_valid(): |
|
218
|
team = form.cleaned_data["team"] |
|
219
|
role = form.cleaned_data["role"] |
|
220
|
ProjectTeam.objects.create(project=project, team=team, role=role, created_by=request.user) |
|
221
|
messages.success(request, f'Team "{team.name}" added with {role} access.') |
|
222
|
return redirect("projects:detail", slug=project.slug) |
|
223
|
else: |
|
224
|
form = ProjectTeamAddForm(project=project) |
|
225
|
|
|
226
|
return render(request, "projects/project_team_add.html", {"form": form, "project": project}) |
|
227
|
|
|
228
|
|
|
229
|
@login_required |
|
230
|
def project_team_edit(request, slug, team_slug): |
|
231
|
P.PROJECT_CHANGE.check(request.user) |
|
232
|
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
|
233
|
team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True) |
|
234
|
project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True) |
|
235
|
|
|
236
|
if request.method == "POST": |
|
237
|
form = ProjectTeamEditForm(request.POST) |
|
238
|
if form.is_valid(): |
|
239
|
project_team.role = form.cleaned_data["role"] |
|
240
|
project_team.updated_by = request.user |
|
241
|
project_team.save() |
|
242
|
messages.success(request, f'Team "{team.name}" role updated to {project_team.role}.') |
|
243
|
return redirect("projects:detail", slug=project.slug) |
|
244
|
else: |
|
245
|
form = ProjectTeamEditForm(initial={"role": project_team.role}) |
|
246
|
|
|
247
|
return render(request, "projects/project_team_edit.html", {"form": form, "project": project, "team": team}) |
|
248
|
|
|
249
|
|
|
250
|
@login_required |
|
251
|
def project_team_remove(request, slug, team_slug): |
|
252
|
P.PROJECT_CHANGE.check(request.user) |
|
253
|
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
|
254
|
team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True) |
|
255
|
project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True) |
|
256
|
|
|
257
|
if request.method == "POST": |
|
258
|
project_team.soft_delete(user=request.user) |
|
259
|
messages.success(request, f'Team "{team.name}" removed from project.') |
|
260
|
|
|
261
|
if request.headers.get("HX-Request"): |
|
262
|
return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"}) |
|
263
|
|
|
264
|
return redirect("projects:detail", slug=project.slug) |
|
265
|
|
|
266
|
return render(request, "projects/project_team_confirm_remove.html", {"project": project, "team": team}) |
|
267
|
|
|
268
|
|
|
269
|
# --- Project Groups --- |
|
270
|
|
|
271
|
|
|
272
|
@login_required |
|
273
|
def group_list(request): |
|
274
|
P.PROJECT_GROUP_VIEW.check(request.user) |
|
275
|
groups = ProjectGroup.objects.all().prefetch_related("projects") |
|
276
|
|
|
277
|
search = request.GET.get("search", "").strip() |
|
278
|
if search: |
|
279
|
groups = groups.filter(name__icontains=search) |
|
280
|
|
|
281
|
per_page = get_per_page(request) |
|
282
|
paginator = Paginator(groups, per_page) |
|
283
|
page_obj = paginator.get_page(request.GET.get("page", 1)) |
|
284
|
|
|
285
|
ctx = {"groups": page_obj, "page_obj": page_obj, "search": search, "per_page": per_page, "per_page_options": PER_PAGE_OPTIONS} |
|
286
|
|
|
287
|
if request.headers.get("HX-Request"): |
|
288
|
return render(request, "projects/partials/group_table.html", ctx) |
|
289
|
|
|
290
|
return render(request, "projects/group_list.html", ctx) |
|
291
|
|
|
292
|
|
|
293
|
@login_required |
|
294
|
def group_create(request): |
|
295
|
P.PROJECT_GROUP_ADD.check(request.user) |
|
296
|
|
|
297
|
if request.method == "POST": |
|
298
|
form = ProjectGroupForm(request.POST) |
|
299
|
if form.is_valid(): |
|
300
|
group = form.save(commit=False) |
|
301
|
group.created_by = request.user |
|
302
|
group.save() |
|
303
|
messages.success(request, f'Group "{group.name}" created.') |
|
304
|
return redirect("projects:group_detail", slug=group.slug) |
|
305
|
else: |
|
306
|
form = ProjectGroupForm() |
|
307
|
|
|
308
|
return render(request, "projects/group_form.html", {"form": form, "title": "New Group"}) |
|
309
|
|
|
310
|
|
|
311
|
@login_required |
|
312
|
def group_detail(request, slug): |
|
313
|
P.PROJECT_GROUP_VIEW.check(request.user) |
|
314
|
group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) |
|
315
|
group_projects = Project.objects.filter(group=group) |
|
316
|
|
|
317
|
return render(request, "projects/group_detail.html", {"group": group, "group_projects": group_projects}) |
|
318
|
|
|
319
|
|
|
320
|
@login_required |
|
321
|
def group_edit(request, slug): |
|
322
|
P.PROJECT_GROUP_CHANGE.check(request.user) |
|
323
|
group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) |
|
324
|
|
|
325
|
if request.method == "POST": |
|
326
|
form = ProjectGroupForm(request.POST, instance=group) |
|
327
|
if form.is_valid(): |
|
328
|
group = form.save(commit=False) |
|
329
|
group.updated_by = request.user |
|
330
|
group.save() |
|
331
|
messages.success(request, f'Group "{group.name}" updated.') |
|
332
|
return redirect("projects:group_detail", slug=group.slug) |
|
333
|
else: |
|
334
|
form = ProjectGroupForm(instance=group) |
|
335
|
|
|
336
|
return render(request, "projects/group_form.html", {"form": form, "group": group, "title": "Edit Group"}) |
|
337
|
|
|
338
|
|
|
339
|
@login_required |
|
340
|
def group_delete(request, slug): |
|
341
|
P.PROJECT_GROUP_DELETE.check(request.user) |
|
342
|
group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) |
|
343
|
|
|
344
|
if request.method == "POST": |
|
345
|
# Unlink projects from this group before soft-deleting |
|
346
|
Project.objects.filter(group=group).update(group=None) |
|
347
|
group.soft_delete(user=request.user) |
|
348
|
messages.success(request, f'Group "{group.name}" deleted.') |
|
349
|
|
|
350
|
if request.headers.get("HX-Request"): |
|
351
|
return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"}) |
|
352
|
|
|
353
|
return redirect("projects:group_list") |
|
354
|
|
|
355
|
return render(request, "projects/group_confirm_delete.html", {"group": group}) |
|
356
|
|
|
357
|
|
|
358
|
# --- Project Starring --- |
|
359
|
|
|
360
|
|
|
361
|
@login_required |
|
362
|
def toggle_star(request, slug): |
|
363
|
"""Toggle star on/off for a project. POST only.""" |
|
364
|
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
|
365
|
star, created = ProjectStar.objects.get_or_create(user=request.user, project=project) |
|
366
|
if not created: |
|
367
|
star.delete() |
|
368
|
is_starred = created |
|
369
|
|
|
370
|
if request.headers.get("HX-Request"): |
|
371
|
return render(request, "projects/partials/star_button.html", {"project": project, "is_starred": is_starred}) |
|
372
|
|
|
373
|
return redirect("projects:detail", slug=slug) |
|
374
|
|
|
375
|
|
|
376
|
# --- Explore / Discover --- |
|
377
|
|
|
378
|
|
|
379
|
def explore(request): |
|
380
|
"""Public project discovery page. Works for anonymous users (public only) and authenticated users (public + internal).""" |
|
381
|
if request.user.is_authenticated: |
|
382
|
allowed_visibility = [Project.Visibility.PUBLIC, Project.Visibility.INTERNAL] |
|
383
|
else: |
|
384
|
allowed_visibility = [Project.Visibility.PUBLIC] |
|
385
|
|
|
386
|
projects = ( |
|
387
|
Project.objects.filter(deleted_at__isnull=True, visibility__in=allowed_visibility) |
|
388
|
.annotate(star_count_annotated=Count("stars")) |
|
389
|
.select_related("organization") |
|
390
|
) |
|
391
|
|
|
392
|
sort = request.GET.get("sort", "stars") |
|
393
|
if sort == "recent": |
|
394
|
projects = projects.order_by("-created_at") |
|
395
|
elif sort == "name": |
|
396
|
projects = projects.order_by("name") |
|
397
|
else: |
|
398
|
sort = "stars" |
|
399
|
projects = projects.order_by("-star_count_annotated", "-created_at") |
|
400
|
|
|
401
|
search = request.GET.get("search", "").strip() |
|
402
|
if search: |
|
403
|
projects = projects.filter(name__icontains=search) |
|
404
|
|
|
405
|
# Check which projects the current user has starred |
|
406
|
starred_ids = set() |
|
407
|
if request.user.is_authenticated: |
|
408
|
starred_ids = set(ProjectStar.objects.filter(user=request.user).values_list("project_id", flat=True)) |
|
409
|
|
|
410
|
return render( |
|
411
|
request, |
|
412
|
"projects/explore.html", |
|
413
|
{ |
|
414
|
"projects": projects, |
|
415
|
"sort": sort, |
|
416
|
"search": search, |
|
417
|
"starred_ids": starred_ids, |
|
418
|
}, |
|
419
|
) |
|
420
|
|