FossilRepo

fossilrepo / projects / views.py
Blame History Raw 420 lines
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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button