FossilRepo

Implement role-based access control + public repo support Access model (projects/access.py): - get_user_role(): returns user's highest role via team membership (admin/write/read/None) - can_read_project(): Public=anyone, Internal=authenticated, Private=team members - can_write_project(): write or admin role required - can_admin_project(): admin role required - require_project_read/write/admin(): raise PermissionDenied helpers Fossil views updated: - Read views: removed @login_required, use require_project_read (public repos now accessible without authentication) - Write views: keep @login_required, use require_project_write - Admin views (sync config): use require_project_admin - _get_repo_and_reader() now accepts request + require level Visibility enforcement: - PUBLIC: anyone can browse code, timeline, tickets, wiki, forum - INTERNAL: any logged-in user can read - PRIVATE: only team members with read/write/admin role - Write operations always require authentication + write role

lmata 2026-04-07 02:33 trunk
Commit 4c30590c93bde6c1b4945859e14d5a72e86b8f928a578188d6140d78f30dbae0
+46 -85
--- fossil/views.py
+++ fossil/views.py
@@ -5,11 +5,10 @@
55
from django.contrib.auth.decorators import login_required
66
from django.http import Http404
77
from django.shortcuts import get_object_or_404, render
88
from django.utils.safestring import mark_safe
99
10
-from core.permissions import P
1110
from projects.models import Project
1211
1312
from .models import FossilRepository
1413
from .reader import FossilReader
1514
@@ -284,13 +283,28 @@
284283
285284
html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/forum(/[^"]*)"', replace_external_forum, html)
286285
return html
287286
288287
289
-def _get_repo_and_reader(slug):
290
- """Return (project, fossil_repo, reader) or raise 404."""
288
+def _get_repo_and_reader(slug, request=None, require="read"):
289
+ """Return (project, fossil_repo, reader) or raise 404/403.
290
+
291
+ require: "read", "write", or "admin"
292
+ """
293
+ from projects.access import require_project_admin, require_project_read, require_project_write
294
+
291295
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
296
+
297
+ # Access check
298
+ if request:
299
+ if require == "admin":
300
+ require_project_admin(request, project)
301
+ elif require == "write":
302
+ require_project_write(request, project)
303
+ else:
304
+ require_project_read(request, project)
305
+
292306
fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
293307
if not fossil_repo.exists_on_disk:
294308
raise Http404("Repository file not found on disk")
295309
reader = FossilReader(fossil_repo.full_path)
296310
return project, fossil_repo, reader
@@ -297,14 +311,12 @@
297311
298312
299313
# --- Code Browser ---
300314
301315
302
-@login_required
303316
def code_browser(request, slug, dirpath=""):
304
- P.PROJECT_VIEW.check(request.user)
305
- project, fossil_repo, reader = _get_repo_and_reader(slug)
317
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
306318
307319
with reader:
308320
checkin_uuid = reader.get_latest_checkin_uuid()
309321
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
310322
metadata = reader.get_metadata()
@@ -358,14 +370,12 @@
358370
"active_tab": "code",
359371
},
360372
)
361373
362374
363
-@login_required
364375
def code_file(request, slug, filepath):
365
- P.PROJECT_VIEW.check(request.user)
366
- project, fossil_repo, reader = _get_repo_and_reader(slug)
376
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
367377
368378
with reader:
369379
checkin_uuid = reader.get_latest_checkin_uuid()
370380
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
371381
@@ -434,14 +444,12 @@
434444
435445
436446
# --- Checkin Detail ---
437447
438448
439
-@login_required
440449
def checkin_detail(request, slug, checkin_uuid):
441
- P.PROJECT_VIEW.check(request.user)
442
- project, fossil_repo, reader = _get_repo_and_reader(slug)
450
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
443451
444452
with reader:
445453
checkin = reader.get_checkin_detail(checkin_uuid)
446454
if not checkin:
447455
raise Http404("Checkin not found")
@@ -541,14 +549,12 @@
541549
542550
543551
# --- Timeline ---
544552
545553
546
-@login_required
547554
def timeline(request, slug):
548
- P.PROJECT_VIEW.check(request.user)
549
- project, fossil_repo, reader = _get_repo_and_reader(slug)
555
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
550556
551557
event_type = request.GET.get("type", "")
552558
page = int(request.GET.get("page", "1"))
553559
per_page = 50
554560
offset = (page - 1) * per_page
@@ -577,14 +583,12 @@
577583
578584
579585
# --- Tickets ---
580586
581587
582
-@login_required
583588
def ticket_list(request, slug):
584
- P.PROJECT_VIEW.check(request.user)
585
- project, fossil_repo, reader = _get_repo_and_reader(slug)
589
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
586590
587591
status_filter = request.GET.get("status", "")
588592
search = request.GET.get("search", "").strip()
589593
page = int(request.GET.get("page", "1"))
590594
per_page = int(request.GET.get("per_page", "50"))
@@ -627,14 +631,12 @@
627631
"active_tab": "tickets",
628632
},
629633
)
630634
631635
632
-@login_required
633636
def ticket_detail(request, slug, ticket_uuid):
634
- P.PROJECT_VIEW.check(request.user)
635
- project, fossil_repo, reader = _get_repo_and_reader(slug)
637
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
636638
637639
with reader:
638640
ticket = reader.get_ticket_detail(ticket_uuid)
639641
comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
640642
@@ -667,14 +669,12 @@
667669
668670
669671
# --- Wiki ---
670672
671673
672
-@login_required
673674
def wiki_list(request, slug):
674
- P.PROJECT_VIEW.check(request.user)
675
- project, fossil_repo, reader = _get_repo_and_reader(slug)
675
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
676676
677677
with reader:
678678
pages = reader.get_wiki_pages()
679679
home_page = reader.get_wiki_page("Home")
680680
@@ -694,14 +694,12 @@
694694
"active_tab": "wiki",
695695
},
696696
)
697697
698698
699
-@login_required
700699
def wiki_page(request, slug, page_name):
701
- P.PROJECT_VIEW.check(request.user)
702
- project, fossil_repo, reader = _get_repo_and_reader(slug)
700
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
703701
704702
with reader:
705703
page = reader.get_wiki_page(page_name)
706704
all_pages = reader.get_wiki_pages()
707705
@@ -725,14 +723,12 @@
725723
726724
727725
# --- Forum ---
728726
729727
730
-@login_required
731728
def forum_list(request, slug):
732
- P.PROJECT_VIEW.check(request.user)
733
- project, fossil_repo, reader = _get_repo_and_reader(slug)
729
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
734730
735731
with reader:
736732
posts = reader.get_forum_posts()
737733
738734
return render(
@@ -745,14 +741,12 @@
745741
"active_tab": "forum",
746742
},
747743
)
748744
749745
750
-@login_required
751746
def forum_thread(request, slug, thread_uuid):
752
- P.PROJECT_VIEW.check(request.user)
753
- project, fossil_repo, reader = _get_repo_and_reader(slug)
747
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
754748
755749
with reader:
756750
posts = reader.get_forum_thread(thread_uuid)
757751
758752
if not posts:
@@ -780,12 +774,11 @@
780774
# --- Wiki CRUD ---
781775
782776
783777
@login_required
784778
def wiki_create(request, slug):
785
- P.PROJECT_CHANGE.check(request.user)
786
- project, fossil_repo, reader = _get_repo_and_reader(slug)
779
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
787780
788781
if request.method == "POST":
789782
page_name = request.POST.get("name", "").strip()
790783
content = request.POST.get("content", "")
791784
if page_name:
@@ -807,12 +800,11 @@
807800
return render(request, "fossil/wiki_form.html", {"project": project, "active_tab": "wiki", "title": "New Wiki Page"})
808801
809802
810803
@login_required
811804
def wiki_edit(request, slug, page_name):
812
- P.PROJECT_CHANGE.check(request.user)
813
- project, fossil_repo, reader = _get_repo_and_reader(slug)
805
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
814806
815807
with reader:
816808
page = reader.get_wiki_page(page_name)
817809
818810
if not page:
@@ -842,12 +834,11 @@
842834
# --- Ticket CRUD ---
843835
844836
845837
@login_required
846838
def ticket_create(request, slug):
847
- P.PROJECT_CHANGE.check(request.user)
848
- project, fossil_repo, reader = _get_repo_and_reader(slug)
839
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
849840
850841
if request.method == "POST":
851842
title = request.POST.get("title", "").strip()
852843
body = request.POST.get("body", "")
853844
ticket_type = request.POST.get("type", "Code_Defect")
@@ -871,12 +862,11 @@
871862
return render(request, "fossil/ticket_form.html", {"project": project, "active_tab": "tickets", "title": "New Ticket"})
872863
873864
874865
@login_required
875866
def ticket_edit(request, slug, ticket_uuid):
876
- P.PROJECT_CHANGE.check(request.user)
877
- project, fossil_repo, reader = _get_repo_and_reader(slug)
867
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
878868
879869
with reader:
880870
ticket = reader.get_ticket_detail(ticket_uuid)
881871
if not ticket:
882872
raise Http404("Ticket not found")
@@ -907,12 +897,11 @@
907897
)
908898
909899
910900
@login_required
911901
def ticket_comment(request, slug, ticket_uuid):
912
- P.PROJECT_CHANGE.check(request.user)
913
- project, fossil_repo, reader = _get_repo_and_reader(slug)
902
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
914903
915904
if request.method == "POST":
916905
comment = request.POST.get("comment", "").strip()
917906
if comment:
918907
from fossil.cli import FossilCLI
@@ -929,14 +918,12 @@
929918
930919
931920
# --- User Activity ---
932921
933922
934
-@login_required
935923
def user_activity(request, slug, username):
936
- P.PROJECT_VIEW.check(request.user)
937
- project, fossil_repo, reader = _get_repo_and_reader(slug)
924
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
938925
939926
with reader:
940927
activity = reader.get_user_activity(username)
941928
942929
import json
@@ -961,12 +948,11 @@
961948
962949
963950
@login_required
964951
def sync_pull(request, slug):
965952
"""Sync configuration and pull from upstream remote."""
966
- P.PROJECT_CHANGE.check(request.user)
967
- project, fossil_repo, reader = _get_repo_and_reader(slug)
953
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
968954
969955
from fossil.cli import FossilCLI
970956
971957
cli = FossilCLI()
972958
result = None
@@ -1046,14 +1032,12 @@
10461032
10471033
10481034
# --- Technotes ---
10491035
10501036
1051
-@login_required
10521037
def technote_list(request, slug):
1053
- P.PROJECT_VIEW.check(request.user)
1054
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1038
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
10551039
10561040
with reader:
10571041
notes = reader.get_technotes()
10581042
10591043
return render(
@@ -1064,15 +1048,13 @@
10641048
10651049
10661050
# --- Compare Checkins ---
10671051
10681052
1069
-@login_required
10701053
def compare_checkins(request, slug):
10711054
"""Compare two checkins side by side."""
1072
- P.PROJECT_VIEW.check(request.user)
1073
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1055
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
10741056
10751057
from_uuid = request.GET.get("from", "")
10761058
to_uuid = request.GET.get("to", "")
10771059
10781060
from_detail = None
@@ -1145,14 +1127,12 @@
11451127
11461128
11471129
# --- Search ---
11481130
11491131
1150
-@login_required
11511132
def search(request, slug):
1152
- P.PROJECT_VIEW.check(request.user)
1153
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1133
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
11541134
11551135
query = request.GET.get("q", "").strip()
11561136
results = None
11571137
if query:
11581138
with reader:
@@ -1171,15 +1151,13 @@
11711151
11721152
11731153
# --- RSS Feed ---
11741154
11751155
1176
-@login_required
11771156
def timeline_rss(request, slug):
11781157
"""RSS feed of recent timeline entries."""
1179
- P.PROJECT_VIEW.check(request.user)
1180
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1158
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
11811159
11821160
with reader:
11831161
entries = reader.get_timeline(limit=30, event_type="ci")
11841162
11851163
from django.http import HttpResponse as DjHttpResponse
@@ -1209,15 +1187,13 @@
12091187
12101188
12111189
# --- CSV Export ---
12121190
12131191
1214
-@login_required
12151192
def tickets_csv(request, slug):
12161193
"""Export all tickets as CSV."""
1217
- P.PROJECT_VIEW.check(request.user)
1218
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1194
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
12191195
12201196
with reader:
12211197
tickets = reader.get_tickets(limit=5000)
12221198
12231199
import csv
@@ -1237,14 +1213,12 @@
12371213
12381214
12391215
# --- File History ---
12401216
12411217
1242
-@login_required
12431218
def file_history(request, slug, filepath):
1244
- P.PROJECT_VIEW.check(request.user)
1245
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1219
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
12461220
12471221
with reader:
12481222
history = reader.get_file_history(filepath)
12491223
12501224
return render(
@@ -1260,14 +1234,12 @@
12601234
12611235
12621236
# --- Branches ---
12631237
12641238
1265
-@login_required
12661239
def branch_list(request, slug):
1267
- P.PROJECT_VIEW.check(request.user)
1268
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1240
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
12691241
12701242
with reader:
12711243
branches = reader.get_branches()
12721244
12731245
return render(
@@ -1283,14 +1255,12 @@
12831255
12841256
12851257
# --- Tags ---
12861258
12871259
1288
-@login_required
12891260
def tag_list(request, slug):
1290
- P.PROJECT_VIEW.check(request.user)
1291
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1261
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
12921262
12931263
with reader:
12941264
tags = reader.get_tags()
12951265
12961266
return render(
@@ -1303,12 +1273,11 @@
13031273
# --- Raw File Download ---
13041274
13051275
13061276
@login_required
13071277
def code_raw(request, slug, filepath):
1308
- P.PROJECT_VIEW.check(request.user)
1309
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1278
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
13101279
13111280
with reader:
13121281
checkin_uuid = reader.get_latest_checkin_uuid()
13131282
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
13141283
target = None
@@ -1329,14 +1298,12 @@
13291298
13301299
13311300
# --- File Blame ---
13321301
13331302
1334
-@login_required
13351303
def code_blame(request, slug, filepath):
1336
- P.PROJECT_VIEW.check(request.user)
1337
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1304
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
13381305
13391306
from fossil.cli import FossilCLI
13401307
13411308
cli = FossilCLI()
13421309
blame_lines = []
@@ -1361,14 +1328,12 @@
13611328
13621329
13631330
# --- Repository Statistics ---
13641331
13651332
1366
-@login_required
13671333
def repo_stats(request, slug):
1368
- P.PROJECT_VIEW.check(request.user)
1369
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1334
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
13701335
13711336
with reader:
13721337
stats = reader.get_repo_statistics()
13731338
top_contributors = reader.get_top_contributors(limit=15)
13741339
activity = reader.get_commit_activity(weeks=52)
@@ -1391,23 +1356,19 @@
13911356
# --- Fossil Docs ---
13921357
13931358
FOSSIL_SCM_SLUG = "fossil-scm"
13941359
13951360
1396
-@login_required
13971361
def fossil_docs(request, slug):
13981362
"""Curated Fossil documentation index page."""
1399
- P.PROJECT_VIEW.check(request.user)
14001363
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
14011364
return render(request, "fossil/docs_index.html", {"project": project, "fossil_scm_slug": slug, "active_tab": "wiki"})
14021365
14031366
1404
-@login_required
14051367
def fossil_doc_page(request, slug, doc_path):
14061368
"""Render a documentation file from the Fossil repo source tree."""
1407
- P.PROJECT_VIEW.check(request.user)
1408
- project, fossil_repo, reader = _get_repo_and_reader(slug)
1369
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
14091370
14101371
with reader:
14111372
checkin_uuid = reader.get_latest_checkin_uuid()
14121373
files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
14131374
14141375
14151376
ADDED projects/access.py
--- fossil/views.py
+++ fossil/views.py
@@ -5,11 +5,10 @@
5 from django.contrib.auth.decorators import login_required
6 from django.http import Http404
7 from django.shortcuts import get_object_or_404, render
8 from django.utils.safestring import mark_safe
9
10 from core.permissions import P
11 from projects.models import Project
12
13 from .models import FossilRepository
14 from .reader import FossilReader
15
@@ -284,13 +283,28 @@
284
285 html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/forum(/[^"]*)"', replace_external_forum, html)
286 return html
287
288
289 def _get_repo_and_reader(slug):
290 """Return (project, fossil_repo, reader) or raise 404."""
 
 
 
 
 
291 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
 
 
 
 
 
 
 
 
 
 
292 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
293 if not fossil_repo.exists_on_disk:
294 raise Http404("Repository file not found on disk")
295 reader = FossilReader(fossil_repo.full_path)
296 return project, fossil_repo, reader
@@ -297,14 +311,12 @@
297
298
299 # --- Code Browser ---
300
301
302 @login_required
303 def code_browser(request, slug, dirpath=""):
304 P.PROJECT_VIEW.check(request.user)
305 project, fossil_repo, reader = _get_repo_and_reader(slug)
306
307 with reader:
308 checkin_uuid = reader.get_latest_checkin_uuid()
309 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
310 metadata = reader.get_metadata()
@@ -358,14 +370,12 @@
358 "active_tab": "code",
359 },
360 )
361
362
363 @login_required
364 def code_file(request, slug, filepath):
365 P.PROJECT_VIEW.check(request.user)
366 project, fossil_repo, reader = _get_repo_and_reader(slug)
367
368 with reader:
369 checkin_uuid = reader.get_latest_checkin_uuid()
370 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
371
@@ -434,14 +444,12 @@
434
435
436 # --- Checkin Detail ---
437
438
439 @login_required
440 def checkin_detail(request, slug, checkin_uuid):
441 P.PROJECT_VIEW.check(request.user)
442 project, fossil_repo, reader = _get_repo_and_reader(slug)
443
444 with reader:
445 checkin = reader.get_checkin_detail(checkin_uuid)
446 if not checkin:
447 raise Http404("Checkin not found")
@@ -541,14 +549,12 @@
541
542
543 # --- Timeline ---
544
545
546 @login_required
547 def timeline(request, slug):
548 P.PROJECT_VIEW.check(request.user)
549 project, fossil_repo, reader = _get_repo_and_reader(slug)
550
551 event_type = request.GET.get("type", "")
552 page = int(request.GET.get("page", "1"))
553 per_page = 50
554 offset = (page - 1) * per_page
@@ -577,14 +583,12 @@
577
578
579 # --- Tickets ---
580
581
582 @login_required
583 def ticket_list(request, slug):
584 P.PROJECT_VIEW.check(request.user)
585 project, fossil_repo, reader = _get_repo_and_reader(slug)
586
587 status_filter = request.GET.get("status", "")
588 search = request.GET.get("search", "").strip()
589 page = int(request.GET.get("page", "1"))
590 per_page = int(request.GET.get("per_page", "50"))
@@ -627,14 +631,12 @@
627 "active_tab": "tickets",
628 },
629 )
630
631
632 @login_required
633 def ticket_detail(request, slug, ticket_uuid):
634 P.PROJECT_VIEW.check(request.user)
635 project, fossil_repo, reader = _get_repo_and_reader(slug)
636
637 with reader:
638 ticket = reader.get_ticket_detail(ticket_uuid)
639 comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
640
@@ -667,14 +669,12 @@
667
668
669 # --- Wiki ---
670
671
672 @login_required
673 def wiki_list(request, slug):
674 P.PROJECT_VIEW.check(request.user)
675 project, fossil_repo, reader = _get_repo_and_reader(slug)
676
677 with reader:
678 pages = reader.get_wiki_pages()
679 home_page = reader.get_wiki_page("Home")
680
@@ -694,14 +694,12 @@
694 "active_tab": "wiki",
695 },
696 )
697
698
699 @login_required
700 def wiki_page(request, slug, page_name):
701 P.PROJECT_VIEW.check(request.user)
702 project, fossil_repo, reader = _get_repo_and_reader(slug)
703
704 with reader:
705 page = reader.get_wiki_page(page_name)
706 all_pages = reader.get_wiki_pages()
707
@@ -725,14 +723,12 @@
725
726
727 # --- Forum ---
728
729
730 @login_required
731 def forum_list(request, slug):
732 P.PROJECT_VIEW.check(request.user)
733 project, fossil_repo, reader = _get_repo_and_reader(slug)
734
735 with reader:
736 posts = reader.get_forum_posts()
737
738 return render(
@@ -745,14 +741,12 @@
745 "active_tab": "forum",
746 },
747 )
748
749
750 @login_required
751 def forum_thread(request, slug, thread_uuid):
752 P.PROJECT_VIEW.check(request.user)
753 project, fossil_repo, reader = _get_repo_and_reader(slug)
754
755 with reader:
756 posts = reader.get_forum_thread(thread_uuid)
757
758 if not posts:
@@ -780,12 +774,11 @@
780 # --- Wiki CRUD ---
781
782
783 @login_required
784 def wiki_create(request, slug):
785 P.PROJECT_CHANGE.check(request.user)
786 project, fossil_repo, reader = _get_repo_and_reader(slug)
787
788 if request.method == "POST":
789 page_name = request.POST.get("name", "").strip()
790 content = request.POST.get("content", "")
791 if page_name:
@@ -807,12 +800,11 @@
807 return render(request, "fossil/wiki_form.html", {"project": project, "active_tab": "wiki", "title": "New Wiki Page"})
808
809
810 @login_required
811 def wiki_edit(request, slug, page_name):
812 P.PROJECT_CHANGE.check(request.user)
813 project, fossil_repo, reader = _get_repo_and_reader(slug)
814
815 with reader:
816 page = reader.get_wiki_page(page_name)
817
818 if not page:
@@ -842,12 +834,11 @@
842 # --- Ticket CRUD ---
843
844
845 @login_required
846 def ticket_create(request, slug):
847 P.PROJECT_CHANGE.check(request.user)
848 project, fossil_repo, reader = _get_repo_and_reader(slug)
849
850 if request.method == "POST":
851 title = request.POST.get("title", "").strip()
852 body = request.POST.get("body", "")
853 ticket_type = request.POST.get("type", "Code_Defect")
@@ -871,12 +862,11 @@
871 return render(request, "fossil/ticket_form.html", {"project": project, "active_tab": "tickets", "title": "New Ticket"})
872
873
874 @login_required
875 def ticket_edit(request, slug, ticket_uuid):
876 P.PROJECT_CHANGE.check(request.user)
877 project, fossil_repo, reader = _get_repo_and_reader(slug)
878
879 with reader:
880 ticket = reader.get_ticket_detail(ticket_uuid)
881 if not ticket:
882 raise Http404("Ticket not found")
@@ -907,12 +897,11 @@
907 )
908
909
910 @login_required
911 def ticket_comment(request, slug, ticket_uuid):
912 P.PROJECT_CHANGE.check(request.user)
913 project, fossil_repo, reader = _get_repo_and_reader(slug)
914
915 if request.method == "POST":
916 comment = request.POST.get("comment", "").strip()
917 if comment:
918 from fossil.cli import FossilCLI
@@ -929,14 +918,12 @@
929
930
931 # --- User Activity ---
932
933
934 @login_required
935 def user_activity(request, slug, username):
936 P.PROJECT_VIEW.check(request.user)
937 project, fossil_repo, reader = _get_repo_and_reader(slug)
938
939 with reader:
940 activity = reader.get_user_activity(username)
941
942 import json
@@ -961,12 +948,11 @@
961
962
963 @login_required
964 def sync_pull(request, slug):
965 """Sync configuration and pull from upstream remote."""
966 P.PROJECT_CHANGE.check(request.user)
967 project, fossil_repo, reader = _get_repo_and_reader(slug)
968
969 from fossil.cli import FossilCLI
970
971 cli = FossilCLI()
972 result = None
@@ -1046,14 +1032,12 @@
1046
1047
1048 # --- Technotes ---
1049
1050
1051 @login_required
1052 def technote_list(request, slug):
1053 P.PROJECT_VIEW.check(request.user)
1054 project, fossil_repo, reader = _get_repo_and_reader(slug)
1055
1056 with reader:
1057 notes = reader.get_technotes()
1058
1059 return render(
@@ -1064,15 +1048,13 @@
1064
1065
1066 # --- Compare Checkins ---
1067
1068
1069 @login_required
1070 def compare_checkins(request, slug):
1071 """Compare two checkins side by side."""
1072 P.PROJECT_VIEW.check(request.user)
1073 project, fossil_repo, reader = _get_repo_and_reader(slug)
1074
1075 from_uuid = request.GET.get("from", "")
1076 to_uuid = request.GET.get("to", "")
1077
1078 from_detail = None
@@ -1145,14 +1127,12 @@
1145
1146
1147 # --- Search ---
1148
1149
1150 @login_required
1151 def search(request, slug):
1152 P.PROJECT_VIEW.check(request.user)
1153 project, fossil_repo, reader = _get_repo_and_reader(slug)
1154
1155 query = request.GET.get("q", "").strip()
1156 results = None
1157 if query:
1158 with reader:
@@ -1171,15 +1151,13 @@
1171
1172
1173 # --- RSS Feed ---
1174
1175
1176 @login_required
1177 def timeline_rss(request, slug):
1178 """RSS feed of recent timeline entries."""
1179 P.PROJECT_VIEW.check(request.user)
1180 project, fossil_repo, reader = _get_repo_and_reader(slug)
1181
1182 with reader:
1183 entries = reader.get_timeline(limit=30, event_type="ci")
1184
1185 from django.http import HttpResponse as DjHttpResponse
@@ -1209,15 +1187,13 @@
1209
1210
1211 # --- CSV Export ---
1212
1213
1214 @login_required
1215 def tickets_csv(request, slug):
1216 """Export all tickets as CSV."""
1217 P.PROJECT_VIEW.check(request.user)
1218 project, fossil_repo, reader = _get_repo_and_reader(slug)
1219
1220 with reader:
1221 tickets = reader.get_tickets(limit=5000)
1222
1223 import csv
@@ -1237,14 +1213,12 @@
1237
1238
1239 # --- File History ---
1240
1241
1242 @login_required
1243 def file_history(request, slug, filepath):
1244 P.PROJECT_VIEW.check(request.user)
1245 project, fossil_repo, reader = _get_repo_and_reader(slug)
1246
1247 with reader:
1248 history = reader.get_file_history(filepath)
1249
1250 return render(
@@ -1260,14 +1234,12 @@
1260
1261
1262 # --- Branches ---
1263
1264
1265 @login_required
1266 def branch_list(request, slug):
1267 P.PROJECT_VIEW.check(request.user)
1268 project, fossil_repo, reader = _get_repo_and_reader(slug)
1269
1270 with reader:
1271 branches = reader.get_branches()
1272
1273 return render(
@@ -1283,14 +1255,12 @@
1283
1284
1285 # --- Tags ---
1286
1287
1288 @login_required
1289 def tag_list(request, slug):
1290 P.PROJECT_VIEW.check(request.user)
1291 project, fossil_repo, reader = _get_repo_and_reader(slug)
1292
1293 with reader:
1294 tags = reader.get_tags()
1295
1296 return render(
@@ -1303,12 +1273,11 @@
1303 # --- Raw File Download ---
1304
1305
1306 @login_required
1307 def code_raw(request, slug, filepath):
1308 P.PROJECT_VIEW.check(request.user)
1309 project, fossil_repo, reader = _get_repo_and_reader(slug)
1310
1311 with reader:
1312 checkin_uuid = reader.get_latest_checkin_uuid()
1313 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
1314 target = None
@@ -1329,14 +1298,12 @@
1329
1330
1331 # --- File Blame ---
1332
1333
1334 @login_required
1335 def code_blame(request, slug, filepath):
1336 P.PROJECT_VIEW.check(request.user)
1337 project, fossil_repo, reader = _get_repo_and_reader(slug)
1338
1339 from fossil.cli import FossilCLI
1340
1341 cli = FossilCLI()
1342 blame_lines = []
@@ -1361,14 +1328,12 @@
1361
1362
1363 # --- Repository Statistics ---
1364
1365
1366 @login_required
1367 def repo_stats(request, slug):
1368 P.PROJECT_VIEW.check(request.user)
1369 project, fossil_repo, reader = _get_repo_and_reader(slug)
1370
1371 with reader:
1372 stats = reader.get_repo_statistics()
1373 top_contributors = reader.get_top_contributors(limit=15)
1374 activity = reader.get_commit_activity(weeks=52)
@@ -1391,23 +1356,19 @@
1391 # --- Fossil Docs ---
1392
1393 FOSSIL_SCM_SLUG = "fossil-scm"
1394
1395
1396 @login_required
1397 def fossil_docs(request, slug):
1398 """Curated Fossil documentation index page."""
1399 P.PROJECT_VIEW.check(request.user)
1400 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1401 return render(request, "fossil/docs_index.html", {"project": project, "fossil_scm_slug": slug, "active_tab": "wiki"})
1402
1403
1404 @login_required
1405 def fossil_doc_page(request, slug, doc_path):
1406 """Render a documentation file from the Fossil repo source tree."""
1407 P.PROJECT_VIEW.check(request.user)
1408 project, fossil_repo, reader = _get_repo_and_reader(slug)
1409
1410 with reader:
1411 checkin_uuid = reader.get_latest_checkin_uuid()
1412 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
1413
1414
1415 DDED projects/access.py
--- fossil/views.py
+++ fossil/views.py
@@ -5,11 +5,10 @@
5 from django.contrib.auth.decorators import login_required
6 from django.http import Http404
7 from django.shortcuts import get_object_or_404, render
8 from django.utils.safestring import mark_safe
9
 
10 from projects.models import Project
11
12 from .models import FossilRepository
13 from .reader import FossilReader
14
@@ -284,13 +283,28 @@
283
284 html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/forum(/[^"]*)"', replace_external_forum, html)
285 return html
286
287
288 def _get_repo_and_reader(slug, request=None, require="read"):
289 """Return (project, fossil_repo, reader) or raise 404/403.
290
291 require: "read", "write", or "admin"
292 """
293 from projects.access import require_project_admin, require_project_read, require_project_write
294
295 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
296
297 # Access check
298 if request:
299 if require == "admin":
300 require_project_admin(request, project)
301 elif require == "write":
302 require_project_write(request, project)
303 else:
304 require_project_read(request, project)
305
306 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
307 if not fossil_repo.exists_on_disk:
308 raise Http404("Repository file not found on disk")
309 reader = FossilReader(fossil_repo.full_path)
310 return project, fossil_repo, reader
@@ -297,14 +311,12 @@
311
312
313 # --- Code Browser ---
314
315
 
316 def code_browser(request, slug, dirpath=""):
317 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
318
319 with reader:
320 checkin_uuid = reader.get_latest_checkin_uuid()
321 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
322 metadata = reader.get_metadata()
@@ -358,14 +370,12 @@
370 "active_tab": "code",
371 },
372 )
373
374
 
375 def code_file(request, slug, filepath):
376 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
377
378 with reader:
379 checkin_uuid = reader.get_latest_checkin_uuid()
380 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
381
@@ -434,14 +444,12 @@
444
445
446 # --- Checkin Detail ---
447
448
 
449 def checkin_detail(request, slug, checkin_uuid):
450 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
451
452 with reader:
453 checkin = reader.get_checkin_detail(checkin_uuid)
454 if not checkin:
455 raise Http404("Checkin not found")
@@ -541,14 +549,12 @@
549
550
551 # --- Timeline ---
552
553
 
554 def timeline(request, slug):
555 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
556
557 event_type = request.GET.get("type", "")
558 page = int(request.GET.get("page", "1"))
559 per_page = 50
560 offset = (page - 1) * per_page
@@ -577,14 +583,12 @@
583
584
585 # --- Tickets ---
586
587
 
588 def ticket_list(request, slug):
589 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
590
591 status_filter = request.GET.get("status", "")
592 search = request.GET.get("search", "").strip()
593 page = int(request.GET.get("page", "1"))
594 per_page = int(request.GET.get("per_page", "50"))
@@ -627,14 +631,12 @@
631 "active_tab": "tickets",
632 },
633 )
634
635
 
636 def ticket_detail(request, slug, ticket_uuid):
637 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
638
639 with reader:
640 ticket = reader.get_ticket_detail(ticket_uuid)
641 comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
642
@@ -667,14 +669,12 @@
669
670
671 # --- Wiki ---
672
673
 
674 def wiki_list(request, slug):
675 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
676
677 with reader:
678 pages = reader.get_wiki_pages()
679 home_page = reader.get_wiki_page("Home")
680
@@ -694,14 +694,12 @@
694 "active_tab": "wiki",
695 },
696 )
697
698
 
699 def wiki_page(request, slug, page_name):
700 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
701
702 with reader:
703 page = reader.get_wiki_page(page_name)
704 all_pages = reader.get_wiki_pages()
705
@@ -725,14 +723,12 @@
723
724
725 # --- Forum ---
726
727
 
728 def forum_list(request, slug):
729 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
730
731 with reader:
732 posts = reader.get_forum_posts()
733
734 return render(
@@ -745,14 +741,12 @@
741 "active_tab": "forum",
742 },
743 )
744
745
 
746 def forum_thread(request, slug, thread_uuid):
747 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
748
749 with reader:
750 posts = reader.get_forum_thread(thread_uuid)
751
752 if not posts:
@@ -780,12 +774,11 @@
774 # --- Wiki CRUD ---
775
776
777 @login_required
778 def wiki_create(request, slug):
779 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
 
780
781 if request.method == "POST":
782 page_name = request.POST.get("name", "").strip()
783 content = request.POST.get("content", "")
784 if page_name:
@@ -807,12 +800,11 @@
800 return render(request, "fossil/wiki_form.html", {"project": project, "active_tab": "wiki", "title": "New Wiki Page"})
801
802
803 @login_required
804 def wiki_edit(request, slug, page_name):
805 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
 
806
807 with reader:
808 page = reader.get_wiki_page(page_name)
809
810 if not page:
@@ -842,12 +834,11 @@
834 # --- Ticket CRUD ---
835
836
837 @login_required
838 def ticket_create(request, slug):
839 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
 
840
841 if request.method == "POST":
842 title = request.POST.get("title", "").strip()
843 body = request.POST.get("body", "")
844 ticket_type = request.POST.get("type", "Code_Defect")
@@ -871,12 +862,11 @@
862 return render(request, "fossil/ticket_form.html", {"project": project, "active_tab": "tickets", "title": "New Ticket"})
863
864
865 @login_required
866 def ticket_edit(request, slug, ticket_uuid):
867 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
 
868
869 with reader:
870 ticket = reader.get_ticket_detail(ticket_uuid)
871 if not ticket:
872 raise Http404("Ticket not found")
@@ -907,12 +897,11 @@
897 )
898
899
900 @login_required
901 def ticket_comment(request, slug, ticket_uuid):
902 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
 
903
904 if request.method == "POST":
905 comment = request.POST.get("comment", "").strip()
906 if comment:
907 from fossil.cli import FossilCLI
@@ -929,14 +918,12 @@
918
919
920 # --- User Activity ---
921
922
 
923 def user_activity(request, slug, username):
924 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
925
926 with reader:
927 activity = reader.get_user_activity(username)
928
929 import json
@@ -961,12 +948,11 @@
948
949
950 @login_required
951 def sync_pull(request, slug):
952 """Sync configuration and pull from upstream remote."""
953 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
 
954
955 from fossil.cli import FossilCLI
956
957 cli = FossilCLI()
958 result = None
@@ -1046,14 +1032,12 @@
1032
1033
1034 # --- Technotes ---
1035
1036
 
1037 def technote_list(request, slug):
1038 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1039
1040 with reader:
1041 notes = reader.get_technotes()
1042
1043 return render(
@@ -1064,15 +1048,13 @@
1048
1049
1050 # --- Compare Checkins ---
1051
1052
 
1053 def compare_checkins(request, slug):
1054 """Compare two checkins side by side."""
1055 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1056
1057 from_uuid = request.GET.get("from", "")
1058 to_uuid = request.GET.get("to", "")
1059
1060 from_detail = None
@@ -1145,14 +1127,12 @@
1127
1128
1129 # --- Search ---
1130
1131
 
1132 def search(request, slug):
1133 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1134
1135 query = request.GET.get("q", "").strip()
1136 results = None
1137 if query:
1138 with reader:
@@ -1171,15 +1151,13 @@
1151
1152
1153 # --- RSS Feed ---
1154
1155
 
1156 def timeline_rss(request, slug):
1157 """RSS feed of recent timeline entries."""
1158 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1159
1160 with reader:
1161 entries = reader.get_timeline(limit=30, event_type="ci")
1162
1163 from django.http import HttpResponse as DjHttpResponse
@@ -1209,15 +1187,13 @@
1187
1188
1189 # --- CSV Export ---
1190
1191
 
1192 def tickets_csv(request, slug):
1193 """Export all tickets as CSV."""
1194 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1195
1196 with reader:
1197 tickets = reader.get_tickets(limit=5000)
1198
1199 import csv
@@ -1237,14 +1213,12 @@
1213
1214
1215 # --- File History ---
1216
1217
 
1218 def file_history(request, slug, filepath):
1219 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1220
1221 with reader:
1222 history = reader.get_file_history(filepath)
1223
1224 return render(
@@ -1260,14 +1234,12 @@
1234
1235
1236 # --- Branches ---
1237
1238
 
1239 def branch_list(request, slug):
1240 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1241
1242 with reader:
1243 branches = reader.get_branches()
1244
1245 return render(
@@ -1283,14 +1255,12 @@
1255
1256
1257 # --- Tags ---
1258
1259
 
1260 def tag_list(request, slug):
1261 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1262
1263 with reader:
1264 tags = reader.get_tags()
1265
1266 return render(
@@ -1303,12 +1273,11 @@
1273 # --- Raw File Download ---
1274
1275
1276 @login_required
1277 def code_raw(request, slug, filepath):
1278 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1279
1280 with reader:
1281 checkin_uuid = reader.get_latest_checkin_uuid()
1282 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
1283 target = None
@@ -1329,14 +1298,12 @@
1298
1299
1300 # --- File Blame ---
1301
1302
 
1303 def code_blame(request, slug, filepath):
1304 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1305
1306 from fossil.cli import FossilCLI
1307
1308 cli = FossilCLI()
1309 blame_lines = []
@@ -1361,14 +1328,12 @@
1328
1329
1330 # --- Repository Statistics ---
1331
1332
 
1333 def repo_stats(request, slug):
1334 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1335
1336 with reader:
1337 stats = reader.get_repo_statistics()
1338 top_contributors = reader.get_top_contributors(limit=15)
1339 activity = reader.get_commit_activity(weeks=52)
@@ -1391,23 +1356,19 @@
1356 # --- Fossil Docs ---
1357
1358 FOSSIL_SCM_SLUG = "fossil-scm"
1359
1360
 
1361 def fossil_docs(request, slug):
1362 """Curated Fossil documentation index page."""
 
1363 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1364 return render(request, "fossil/docs_index.html", {"project": project, "fossil_scm_slug": slug, "active_tab": "wiki"})
1365
1366
 
1367 def fossil_doc_page(request, slug, doc_path):
1368 """Render a documentation file from the Fossil repo source tree."""
1369 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
 
1370
1371 with reader:
1372 checkin_uuid = reader.get_latest_checkin_uuid()
1373 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
1374
1375
1376 DDED projects/access.py
--- a/projects/access.py
+++ b/projects/access.py
@@ -0,0 +1,55 @@
1
+"""Project-level access control based on visibility and team roles.
2
+
3
+Usage in views:
4
+ from projects.access import can_read_project, can_write_project, require_project_read
5
+
6
+ # Check and raise 403/redirect
7
+ require_project_read(request, project)
8
+
9
+ # Boolean check
10
+ if can_write_project(request.user, project):
11
+ ...
12
+"""
13
+
14
+from django.core.exceptions import PermissionDenied
15
+
16
+from projects.models import Project, ProjectTeam
17
+
18
+
19
+def get_user_role(user, project: Project) -> str | None:
20
+ """Get the highest role a user has on a project via their teams.
21
+
22
+ Returns "admin", "write", "read", or None.
23
+ """
24
+ if not user or not user.is_authenticatedcated or not user.is_active:
25
+ return None
26
+
27
+ if user.is_superuser:
28
+ return "admin"
29
+
30
+ # Check all teams the user belongs to that are assigned to this project
31
+ user_team_ids = set(user.teams.values_list("id", flat=True))
32
+ project_teams = ProjectTeam.objects.filter(project=project, team_id__in=user_team_ids, deleted_at__isnull=True).values_list(
33
+ "role", flat=True
34
+ )
35
+
36
+ roles = set(project_teams)
37
+ if "admin" in roles:
38
+ return "admin"
39
+ if "write" in roles:
40
+ return "write"
41
+ if "read" in roles:
42
+ return "read"
43
+ return None
44
+
45
+
46
+def can_read_project(user, project: Project) -> bool:
47
+ """Can this user read the project?
48
+
49
+ - Public: anyone (even anonymous)
50
+ - Internal: any authenticated user
51
+ - Private: team members only (or superuser)
52
+ """
53
+ if project.visibility == "public":
54
+ return True
55
+
--- a/projects/access.py
+++ b/projects/access.py
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/access.py
+++ b/projects/access.py
@@ -0,0 +1,55 @@
1 """Project-level access control based on visibility and team roles.
2
3 Usage in views:
4 from projects.access import can_read_project, can_write_project, require_project_read
5
6 # Check and raise 403/redirect
7 require_project_read(request, project)
8
9 # Boolean check
10 if can_write_project(request.user, project):
11 ...
12 """
13
14 from django.core.exceptions import PermissionDenied
15
16 from projects.models import Project, ProjectTeam
17
18
19 def get_user_role(user, project: Project) -> str | None:
20 """Get the highest role a user has on a project via their teams.
21
22 Returns "admin", "write", "read", or None.
23 """
24 if not user or not user.is_authenticatedcated or not user.is_active:
25 return None
26
27 if user.is_superuser:
28 return "admin"
29
30 # Check all teams the user belongs to that are assigned to this project
31 user_team_ids = set(user.teams.values_list("id", flat=True))
32 project_teams = ProjectTeam.objects.filter(project=project, team_id__in=user_team_ids, deleted_at__isnull=True).values_list(
33 "role", flat=True
34 )
35
36 roles = set(project_teams)
37 if "admin" in roles:
38 return "admin"
39 if "write" in roles:
40 return "write"
41 if "read" in roles:
42 return "read"
43 return None
44
45
46 def can_read_project(user, project: Project) -> bool:
47 """Can this user read the project?
48
49 - Public: anyone (even anonymous)
50 - Internal: any authenticated user
51 - Private: team members only (or superuser)
52 """
53 if project.visibility == "public":
54 return True
55

Keyboard Shortcuts

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