FossilRepo
Add tags, raw download, repo statistics, fix external forum links Tags: - New /fossil/tags/ view listing all version tags with checkin links Raw file download: - /fossil/code/raw/<path> serves file as octet-stream attachment - "Raw" button on code file viewer header Repository statistics: - /fossil/stats/ with comprehensive stats dashboard - Stats cards: checkins, contributors, artifacts, repo size - 52-week activity chart (Chart.js) - Event breakdown: checkins, wiki, tickets, forum counts - Top contributors with progress bars - First/last checkin dates Link fixes: - Only rewrite fossil-scm.org/home links (source repo), not /forum - External forum links stay external (separate Fossil instance)
Commit
6140f5bde8f73752d64bbbaad647a7a51afcc89218ea39128c5ec705e834b5d1
Parent
4cf64702dd2452d…
6 files changed
+60
+3
+81
-6
+1
+76
+13
+60
| --- fossil/reader.py | ||
| +++ fossil/reader.py | ||
| @@ -402,10 +402,70 @@ | ||
| 402 | 402 | } |
| 403 | 403 | ) |
| 404 | 404 | except sqlite3.OperationalError: |
| 405 | 405 | pass |
| 406 | 406 | return branches |
| 407 | + | |
| 408 | + def get_tags(self) -> list[dict]: | |
| 409 | + """Get all tags (non-branch sym- tags that mark specific checkins).""" | |
| 410 | + tags = [] | |
| 411 | + try: | |
| 412 | + rows = self.conn.execute( | |
| 413 | + """ | |
| 414 | + SELECT tag.tagname, event.mtime, event.user, blob.uuid | |
| 415 | + FROM tag | |
| 416 | + JOIN tagxref ON tag.tagid = tagxref.tagid AND tagxref.value > 0 | |
| 417 | + JOIN event ON tagxref.rid = event.objid | |
| 418 | + JOIN blob ON event.objid = blob.rid | |
| 419 | + WHERE tag.tagname LIKE 'sym-%' | |
| 420 | + AND tag.tagname NOT IN (SELECT tagname FROM tag JOIN tagxref ON tag.tagid=tagxref.tagid GROUP BY tagname HAVING count(*) > 5) | |
| 421 | + ORDER BY event.mtime DESC | |
| 422 | + LIMIT 100 | |
| 423 | + """, | |
| 424 | + ).fetchall() | |
| 425 | + for r in rows: | |
| 426 | + tags.append( | |
| 427 | + { | |
| 428 | + "name": r["tagname"].replace("sym-", "", 1), | |
| 429 | + "timestamp": _julian_to_datetime(r["mtime"]), | |
| 430 | + "user": r["user"] or "", | |
| 431 | + "uuid": r["uuid"], | |
| 432 | + } | |
| 433 | + ) | |
| 434 | + except sqlite3.OperationalError: | |
| 435 | + pass | |
| 436 | + return tags | |
| 437 | + | |
| 438 | + def get_repo_statistics(self) -> dict: | |
| 439 | + """Get comprehensive repository statistics.""" | |
| 440 | + stats = {} | |
| 441 | + try: | |
| 442 | + stats["total_artifacts"] = self.conn.execute("SELECT count(*) FROM blob").fetchone()[0] | |
| 443 | + stats["total_events"] = self.conn.execute("SELECT count(*) FROM event").fetchone()[0] | |
| 444 | + stats["checkin_count"] = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone()[0] | |
| 445 | + stats["wiki_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='w'").fetchone()[0] | |
| 446 | + stats["ticket_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='t'").fetchone()[0] | |
| 447 | + stats["forum_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='f'").fetchone()[0] | |
| 448 | + | |
| 449 | + # First and last checkin dates | |
| 450 | + first = self.conn.execute("SELECT min(mtime) FROM event WHERE type='ci'").fetchone() | |
| 451 | + last = self.conn.execute("SELECT max(mtime) FROM event WHERE type='ci'").fetchone() | |
| 452 | + if first and first[0]: | |
| 453 | + stats["first_checkin"] = _julian_to_datetime(first[0]) | |
| 454 | + if last and last[0]: | |
| 455 | + stats["last_checkin"] = _julian_to_datetime(last[0]) | |
| 456 | + | |
| 457 | + # Unique contributors | |
| 458 | + stats["contributors"] = self.conn.execute("SELECT count(DISTINCT user) FROM event WHERE type='ci'").fetchone()[0] | |
| 459 | + | |
| 460 | + # DB size | |
| 461 | + stats["db_pages"] = self.conn.execute("PRAGMA page_count").fetchone()[0] | |
| 462 | + stats["page_size"] = self.conn.execute("PRAGMA page_size").fetchone()[0] | |
| 463 | + stats["db_size_mb"] = round((stats["db_pages"] * stats["page_size"]) / (1024 * 1024), 1) | |
| 464 | + except sqlite3.OperationalError: | |
| 465 | + pass | |
| 466 | + return stats | |
| 407 | 467 | |
| 408 | 468 | # --- Timeline --- |
| 409 | 469 | |
| 410 | 470 | def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]: |
| 411 | 471 | sql = """ |
| 412 | 472 |
| --- fossil/reader.py | |
| +++ fossil/reader.py | |
| @@ -402,10 +402,70 @@ | |
| 402 | } |
| 403 | ) |
| 404 | except sqlite3.OperationalError: |
| 405 | pass |
| 406 | return branches |
| 407 | |
| 408 | # --- Timeline --- |
| 409 | |
| 410 | def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]: |
| 411 | sql = """ |
| 412 |
| --- fossil/reader.py | |
| +++ fossil/reader.py | |
| @@ -402,10 +402,70 @@ | |
| 402 | } |
| 403 | ) |
| 404 | except sqlite3.OperationalError: |
| 405 | pass |
| 406 | return branches |
| 407 | |
| 408 | def get_tags(self) -> list[dict]: |
| 409 | """Get all tags (non-branch sym- tags that mark specific checkins).""" |
| 410 | tags = [] |
| 411 | try: |
| 412 | rows = self.conn.execute( |
| 413 | """ |
| 414 | SELECT tag.tagname, event.mtime, event.user, blob.uuid |
| 415 | FROM tag |
| 416 | JOIN tagxref ON tag.tagid = tagxref.tagid AND tagxref.value > 0 |
| 417 | JOIN event ON tagxref.rid = event.objid |
| 418 | JOIN blob ON event.objid = blob.rid |
| 419 | WHERE tag.tagname LIKE 'sym-%' |
| 420 | AND tag.tagname NOT IN (SELECT tagname FROM tag JOIN tagxref ON tag.tagid=tagxref.tagid GROUP BY tagname HAVING count(*) > 5) |
| 421 | ORDER BY event.mtime DESC |
| 422 | LIMIT 100 |
| 423 | """, |
| 424 | ).fetchall() |
| 425 | for r in rows: |
| 426 | tags.append( |
| 427 | { |
| 428 | "name": r["tagname"].replace("sym-", "", 1), |
| 429 | "timestamp": _julian_to_datetime(r["mtime"]), |
| 430 | "user": r["user"] or "", |
| 431 | "uuid": r["uuid"], |
| 432 | } |
| 433 | ) |
| 434 | except sqlite3.OperationalError: |
| 435 | pass |
| 436 | return tags |
| 437 | |
| 438 | def get_repo_statistics(self) -> dict: |
| 439 | """Get comprehensive repository statistics.""" |
| 440 | stats = {} |
| 441 | try: |
| 442 | stats["total_artifacts"] = self.conn.execute("SELECT count(*) FROM blob").fetchone()[0] |
| 443 | stats["total_events"] = self.conn.execute("SELECT count(*) FROM event").fetchone()[0] |
| 444 | stats["checkin_count"] = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone()[0] |
| 445 | stats["wiki_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='w'").fetchone()[0] |
| 446 | stats["ticket_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='t'").fetchone()[0] |
| 447 | stats["forum_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='f'").fetchone()[0] |
| 448 | |
| 449 | # First and last checkin dates |
| 450 | first = self.conn.execute("SELECT min(mtime) FROM event WHERE type='ci'").fetchone() |
| 451 | last = self.conn.execute("SELECT max(mtime) FROM event WHERE type='ci'").fetchone() |
| 452 | if first and first[0]: |
| 453 | stats["first_checkin"] = _julian_to_datetime(first[0]) |
| 454 | if last and last[0]: |
| 455 | stats["last_checkin"] = _julian_to_datetime(last[0]) |
| 456 | |
| 457 | # Unique contributors |
| 458 | stats["contributors"] = self.conn.execute("SELECT count(DISTINCT user) FROM event WHERE type='ci'").fetchone()[0] |
| 459 | |
| 460 | # DB size |
| 461 | stats["db_pages"] = self.conn.execute("PRAGMA page_count").fetchone()[0] |
| 462 | stats["page_size"] = self.conn.execute("PRAGMA page_size").fetchone()[0] |
| 463 | stats["db_size_mb"] = round((stats["db_pages"] * stats["page_size"]) / (1024 * 1024), 1) |
| 464 | except sqlite3.OperationalError: |
| 465 | pass |
| 466 | return stats |
| 467 | |
| 468 | # --- Timeline --- |
| 469 | |
| 470 | def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]: |
| 471 | sql = """ |
| 472 |
+3
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -19,10 +19,13 @@ | ||
| 19 | 19 | path("tickets/create/", views.ticket_create, name="ticket_create"), |
| 20 | 20 | path("forum/", views.forum_list, name="forum"), |
| 21 | 21 | path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), |
| 22 | 22 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 23 | 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | + path("tags/", views.tag_list, name="tags"), | |
| 24 | 25 | path("search/", views.search, name="search"), |
| 26 | + path("stats/", views.repo_stats, name="stats"), | |
| 27 | + path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), | |
| 25 | 28 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 26 | 29 | path("docs/", views.fossil_docs, name="docs"), |
| 27 | 30 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 28 | 31 | ] |
| 29 | 32 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -19,10 +19,13 @@ | |
| 19 | path("tickets/create/", views.ticket_create, name="ticket_create"), |
| 20 | path("forum/", views.forum_list, name="forum"), |
| 21 | path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), |
| 22 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | path("search/", views.search, name="search"), |
| 25 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 26 | path("docs/", views.fossil_docs, name="docs"), |
| 27 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 28 | ] |
| 29 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -19,10 +19,13 @@ | |
| 19 | path("tickets/create/", views.ticket_create, name="ticket_create"), |
| 20 | path("forum/", views.forum_list, name="forum"), |
| 21 | path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), |
| 22 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | path("search/", views.search, name="search"), |
| 26 | path("stats/", views.repo_stats, name="stats"), |
| 27 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 28 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 29 | path("docs/", views.fossil_docs, name="docs"), |
| 30 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 31 | ] |
| 32 |
+81
-6
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -243,28 +243,29 @@ | ||
| 243 | 243 | # Rewrite href="/..." links (internal Fossil paths) |
| 244 | 244 | html = re.sub(r'href="(/[^"]*)"', replace_link, html) |
| 245 | 245 | # Rewrite Fossil URI schemes: forum:/..., info:..., wiki:... |
| 246 | 246 | html = re.sub(r'href="(forum|info|wiki):([^"]*)"', replace_scheme_link, html) |
| 247 | 247 | |
| 248 | - # Rewrite external fossil-scm.org links to local views | |
| 248 | + # Rewrite external fossil-scm.org/home links (source repo) to local views | |
| 249 | + # Do NOT rewrite fossil-scm.org/forum links — those are a separate repo/instance | |
| 249 | 250 | def replace_external_fossil(match): |
| 250 | 251 | path = match.group(1) |
| 251 | - # /forum/forumpost/HASH | |
| 252 | - m = re.match(r"/forum/forumpost/([0-9a-f]+)", path) | |
| 253 | - if m: | |
| 254 | - return f'href="{base}/forum/{m.group(1)}/"' | |
| 255 | 252 | # /info/HASH |
| 256 | 253 | m = re.match(r"/info/([0-9a-f]+)", path) |
| 257 | 254 | if m: |
| 258 | 255 | return f'href="{base}/checkin/{m.group(1)}/"' |
| 259 | 256 | # /wiki/PageName |
| 260 | 257 | m = re.match(r"/wiki/(.+)", path) |
| 261 | 258 | if m: |
| 262 | 259 | return f'href="{base}/wiki/page/{m.group(1)}"' |
| 260 | + # /doc/trunk/www/file -> docs | |
| 261 | + m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", path) | |
| 262 | + if m: | |
| 263 | + return f'href="{base}/docs/{m.group(1)}"' | |
| 263 | 264 | return match.group(0) |
| 264 | 265 | |
| 265 | - html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org(?:/home)?(/[^"]*)"', replace_external_fossil, html) | |
| 266 | + html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/home(/[^"]*)"', replace_external_fossil, html) | |
| 266 | 267 | return html |
| 267 | 268 | |
| 268 | 269 | |
| 269 | 270 | def _get_repo_and_reader(slug): |
| 270 | 271 | """Return (project, fossil_repo, reader) or raise 404.""" |
| @@ -933,10 +934,84 @@ | ||
| 933 | 934 | "branches": branches, |
| 934 | 935 | "active_tab": "code", |
| 935 | 936 | }, |
| 936 | 937 | ) |
| 937 | 938 | |
| 939 | + | |
| 940 | +# --- Tags --- | |
| 941 | + | |
| 942 | + | |
| 943 | +@login_required | |
| 944 | +def tag_list(request, slug): | |
| 945 | + P.PROJECT_VIEW.check(request.user) | |
| 946 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 947 | + | |
| 948 | + with reader: | |
| 949 | + tags = reader.get_tags() | |
| 950 | + | |
| 951 | + return render( | |
| 952 | + request, | |
| 953 | + "fossil/tag_list.html", | |
| 954 | + {"project": project, "tags": tags, "active_tab": "code"}, | |
| 955 | + ) | |
| 956 | + | |
| 957 | + | |
| 958 | +# --- Raw File Download --- | |
| 959 | + | |
| 960 | + | |
| 961 | +@login_required | |
| 962 | +def code_raw(request, slug, filepath): | |
| 963 | + P.PROJECT_VIEW.check(request.user) | |
| 964 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 965 | + | |
| 966 | + with reader: | |
| 967 | + checkin_uuid = reader.get_latest_checkin_uuid() | |
| 968 | + files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else [] | |
| 969 | + target = None | |
| 970 | + for f in files: | |
| 971 | + if f.name == filepath: | |
| 972 | + target = f | |
| 973 | + break | |
| 974 | + if not target: | |
| 975 | + raise Http404(f"File not found: {filepath}") | |
| 976 | + content_bytes = reader.get_file_content(target.uuid) | |
| 977 | + | |
| 978 | + from django.http import HttpResponse as DjHttpResponse | |
| 979 | + | |
| 980 | + filename = filepath.split("/")[-1] | |
| 981 | + response = DjHttpResponse(content_bytes, content_type="application/octet-stream") | |
| 982 | + response["Content-Disposition"] = f'attachment; filename="{filename}"' | |
| 983 | + return response | |
| 984 | + | |
| 985 | + | |
| 986 | +# --- Repository Statistics --- | |
| 987 | + | |
| 988 | + | |
| 989 | +@login_required | |
| 990 | +def repo_stats(request, slug): | |
| 991 | + P.PROJECT_VIEW.check(request.user) | |
| 992 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 993 | + | |
| 994 | + with reader: | |
| 995 | + stats = reader.get_repo_statistics() | |
| 996 | + top_contributors = reader.get_top_contributors(limit=15) | |
| 997 | + activity = reader.get_commit_activity(weeks=52) | |
| 998 | + | |
| 999 | + import json | |
| 1000 | + | |
| 1001 | + return render( | |
| 1002 | + request, | |
| 1003 | + "fossil/repo_stats.html", | |
| 1004 | + { | |
| 1005 | + "project": project, | |
| 1006 | + "stats": stats, | |
| 1007 | + "top_contributors": top_contributors, | |
| 1008 | + "activity_json": json.dumps([c["count"] for c in activity]), | |
| 1009 | + "active_tab": "code", | |
| 1010 | + }, | |
| 1011 | + ) | |
| 1012 | + | |
| 938 | 1013 | |
| 939 | 1014 | # --- Fossil Docs --- |
| 940 | 1015 | |
| 941 | 1016 | FOSSIL_SCM_SLUG = "fossil-scm" |
| 942 | 1017 | |
| 943 | 1018 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -243,28 +243,29 @@ | |
| 243 | # Rewrite href="/..." links (internal Fossil paths) |
| 244 | html = re.sub(r'href="(/[^"]*)"', replace_link, html) |
| 245 | # Rewrite Fossil URI schemes: forum:/..., info:..., wiki:... |
| 246 | html = re.sub(r'href="(forum|info|wiki):([^"]*)"', replace_scheme_link, html) |
| 247 | |
| 248 | # Rewrite external fossil-scm.org links to local views |
| 249 | def replace_external_fossil(match): |
| 250 | path = match.group(1) |
| 251 | # /forum/forumpost/HASH |
| 252 | m = re.match(r"/forum/forumpost/([0-9a-f]+)", path) |
| 253 | if m: |
| 254 | return f'href="{base}/forum/{m.group(1)}/"' |
| 255 | # /info/HASH |
| 256 | m = re.match(r"/info/([0-9a-f]+)", path) |
| 257 | if m: |
| 258 | return f'href="{base}/checkin/{m.group(1)}/"' |
| 259 | # /wiki/PageName |
| 260 | m = re.match(r"/wiki/(.+)", path) |
| 261 | if m: |
| 262 | return f'href="{base}/wiki/page/{m.group(1)}"' |
| 263 | return match.group(0) |
| 264 | |
| 265 | html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org(?:/home)?(/[^"]*)"', replace_external_fossil, html) |
| 266 | return html |
| 267 | |
| 268 | |
| 269 | def _get_repo_and_reader(slug): |
| 270 | """Return (project, fossil_repo, reader) or raise 404.""" |
| @@ -933,10 +934,84 @@ | |
| 933 | "branches": branches, |
| 934 | "active_tab": "code", |
| 935 | }, |
| 936 | ) |
| 937 | |
| 938 | |
| 939 | # --- Fossil Docs --- |
| 940 | |
| 941 | FOSSIL_SCM_SLUG = "fossil-scm" |
| 942 | |
| 943 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -243,28 +243,29 @@ | |
| 243 | # Rewrite href="/..." links (internal Fossil paths) |
| 244 | html = re.sub(r'href="(/[^"]*)"', replace_link, html) |
| 245 | # Rewrite Fossil URI schemes: forum:/..., info:..., wiki:... |
| 246 | html = re.sub(r'href="(forum|info|wiki):([^"]*)"', replace_scheme_link, html) |
| 247 | |
| 248 | # Rewrite external fossil-scm.org/home links (source repo) to local views |
| 249 | # Do NOT rewrite fossil-scm.org/forum links — those are a separate repo/instance |
| 250 | def replace_external_fossil(match): |
| 251 | path = match.group(1) |
| 252 | # /info/HASH |
| 253 | m = re.match(r"/info/([0-9a-f]+)", path) |
| 254 | if m: |
| 255 | return f'href="{base}/checkin/{m.group(1)}/"' |
| 256 | # /wiki/PageName |
| 257 | m = re.match(r"/wiki/(.+)", path) |
| 258 | if m: |
| 259 | return f'href="{base}/wiki/page/{m.group(1)}"' |
| 260 | # /doc/trunk/www/file -> docs |
| 261 | m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", path) |
| 262 | if m: |
| 263 | return f'href="{base}/docs/{m.group(1)}"' |
| 264 | return match.group(0) |
| 265 | |
| 266 | html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/home(/[^"]*)"', replace_external_fossil, html) |
| 267 | return html |
| 268 | |
| 269 | |
| 270 | def _get_repo_and_reader(slug): |
| 271 | """Return (project, fossil_repo, reader) or raise 404.""" |
| @@ -933,10 +934,84 @@ | |
| 934 | "branches": branches, |
| 935 | "active_tab": "code", |
| 936 | }, |
| 937 | ) |
| 938 | |
| 939 | |
| 940 | # --- Tags --- |
| 941 | |
| 942 | |
| 943 | @login_required |
| 944 | def tag_list(request, slug): |
| 945 | P.PROJECT_VIEW.check(request.user) |
| 946 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 947 | |
| 948 | with reader: |
| 949 | tags = reader.get_tags() |
| 950 | |
| 951 | return render( |
| 952 | request, |
| 953 | "fossil/tag_list.html", |
| 954 | {"project": project, "tags": tags, "active_tab": "code"}, |
| 955 | ) |
| 956 | |
| 957 | |
| 958 | # --- Raw File Download --- |
| 959 | |
| 960 | |
| 961 | @login_required |
| 962 | def code_raw(request, slug, filepath): |
| 963 | P.PROJECT_VIEW.check(request.user) |
| 964 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 965 | |
| 966 | with reader: |
| 967 | checkin_uuid = reader.get_latest_checkin_uuid() |
| 968 | files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else [] |
| 969 | target = None |
| 970 | for f in files: |
| 971 | if f.name == filepath: |
| 972 | target = f |
| 973 | break |
| 974 | if not target: |
| 975 | raise Http404(f"File not found: {filepath}") |
| 976 | content_bytes = reader.get_file_content(target.uuid) |
| 977 | |
| 978 | from django.http import HttpResponse as DjHttpResponse |
| 979 | |
| 980 | filename = filepath.split("/")[-1] |
| 981 | response = DjHttpResponse(content_bytes, content_type="application/octet-stream") |
| 982 | response["Content-Disposition"] = f'attachment; filename="{filename}"' |
| 983 | return response |
| 984 | |
| 985 | |
| 986 | # --- Repository Statistics --- |
| 987 | |
| 988 | |
| 989 | @login_required |
| 990 | def repo_stats(request, slug): |
| 991 | P.PROJECT_VIEW.check(request.user) |
| 992 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 993 | |
| 994 | with reader: |
| 995 | stats = reader.get_repo_statistics() |
| 996 | top_contributors = reader.get_top_contributors(limit=15) |
| 997 | activity = reader.get_commit_activity(weeks=52) |
| 998 | |
| 999 | import json |
| 1000 | |
| 1001 | return render( |
| 1002 | request, |
| 1003 | "fossil/repo_stats.html", |
| 1004 | { |
| 1005 | "project": project, |
| 1006 | "stats": stats, |
| 1007 | "top_contributors": top_contributors, |
| 1008 | "activity_json": json.dumps([c["count"] for c in activity]), |
| 1009 | "active_tab": "code", |
| 1010 | }, |
| 1011 | ) |
| 1012 | |
| 1013 | |
| 1014 | # --- Fossil Docs --- |
| 1015 | |
| 1016 | FOSSIL_SCM_SLUG = "fossil-scm" |
| 1017 | |
| 1018 |
| --- templates/fossil/code_file.html | ||
| +++ templates/fossil/code_file.html | ||
| @@ -63,10 +63,11 @@ | ||
| 63 | 63 | <a href="?mode=source" class="px-2 py-1 rounded {% if view_mode == 'source' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Source</a> |
| 64 | 64 | <a href="?mode=rendered" class="px-2 py-1 rounded {% if view_mode == 'rendered' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Rendered</a> |
| 65 | 65 | </div> |
| 66 | 66 | {% endif %} |
| 67 | 67 | <a href="{% url 'fossil:file_history' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">History</a> |
| 68 | + <a href="{% url 'fossil:code_raw' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">Raw</a> | |
| 68 | 69 | {% if not is_binary %} |
| 69 | 70 | <span class="text-xs text-gray-500">{{ line_count }} line{{ line_count|pluralize }}</span> |
| 70 | 71 | {% endif %} |
| 71 | 72 | </div> |
| 72 | 73 | </div> |
| 73 | 74 | |
| 74 | 75 | ADDED templates/fossil/repo_stats.html |
| 75 | 76 | ADDED templates/fossil/tag_list.html |
| --- templates/fossil/code_file.html | |
| +++ templates/fossil/code_file.html | |
| @@ -63,10 +63,11 @@ | |
| 63 | <a href="?mode=source" class="px-2 py-1 rounded {% if view_mode == 'source' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Source</a> |
| 64 | <a href="?mode=rendered" class="px-2 py-1 rounded {% if view_mode == 'rendered' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Rendered</a> |
| 65 | </div> |
| 66 | {% endif %} |
| 67 | <a href="{% url 'fossil:file_history' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">History</a> |
| 68 | {% if not is_binary %} |
| 69 | <span class="text-xs text-gray-500">{{ line_count }} line{{ line_count|pluralize }}</span> |
| 70 | {% endif %} |
| 71 | </div> |
| 72 | </div> |
| 73 | |
| 74 | DDED templates/fossil/repo_stats.html |
| 75 | DDED templates/fossil/tag_list.html |
| --- templates/fossil/code_file.html | |
| +++ templates/fossil/code_file.html | |
| @@ -63,10 +63,11 @@ | |
| 63 | <a href="?mode=source" class="px-2 py-1 rounded {% if view_mode == 'source' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Source</a> |
| 64 | <a href="?mode=rendered" class="px-2 py-1 rounded {% if view_mode == 'rendered' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Rendered</a> |
| 65 | </div> |
| 66 | {% endif %} |
| 67 | <a href="{% url 'fossil:file_history' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">History</a> |
| 68 | <a href="{% url 'fossil:code_raw' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">Raw</a> |
| 69 | {% if not is_binary %} |
| 70 | <span class="text-xs text-gray-500">{{ line_count }} line{{ line_count|pluralize }}</span> |
| 71 | {% endif %} |
| 72 | </div> |
| 73 | </div> |
| 74 | |
| 75 | DDED templates/fossil/repo_stats.html |
| 76 | DDED templates/fossil/tag_list.html |
| --- a/templates/fossil/repo_stats.html | ||
| +++ b/templates/fossil/repo_stats.html | ||
| @@ -0,0 +1,76 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load fossil_filters %} | |
| 3 | +{% block title %}Statistics — {{ project.name }} — Fossilrepo{% endblock %} | |
| 4 | + | |
| 5 | +{% block extra_head %} | |
| 6 | +<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 11 | +{% include "fossil/_project_nav.html" %} | |
| 12 | + | |
| 13 | +<div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6"> | |
| 14 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> | |
| 15 | + <div class="text-2xl font-bold text-gray-100">{{ stats.checkin_count|default:"0" }}</div> | |
| 16 | + <div class="text-xs text-gray-500 mt-1">Checkins</div> | |
| 17 | + </div> | |
| 18 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> | |
| 19 | + <div class="text-2xl font-bold text-gray-100">{{ stats.contributors|default:"0" }}</div> | |
| 20 | + <div class="text-xs text-gray-500 mt-1">Contributors</div> | |
| 21 | + </div> | |
| 22 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> | |
| 23 | + <div class="text-2xl font-bold text-gray-100">{{ stats.total_artifacts|default:"0" }}</div> | |
| 24 | + <div class="text-xs text-gray-500 mt-1">Artifacts</div> | |
| 25 | + </div> | |
| 26 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> | |
| 27 | + <div class="text-2xl font-bold text-gray-100">{{ stats.db_size_mb|default:"0" }} MB</div> | |
| 28 | + <div class="text-xs text-gray-500 mt-1">Repository Size</div> | |
| 29 | + </div> | |
| 30 | +</div> | |
| 31 | + | |
| 32 | +{% if activity_json %} | |
| 33 | +<div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6"> | |
| 34 | + <h3 class="text-sm font-medium text-gray-300 mb-3">Commit Activity (52 weeks)</h3> | |
| 35 | + <div style="height: 160px;"> | |
| 36 | + <canvas id="statsChart"></canvas> | |
| 37 | + </div> | |
| 38 | +</div> | |
| 39 | +{% endif %} | |
| 40 | + | |
| 41 | +<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> | |
| 42 | + <!-- Event breakdown --> | |
| 43 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> | |
| 44 | + <h3 class="text-sm font-medium text-gray-300 mb-3">Event Breakdown</h3> | |
| 45 | + <div class="space-y-2"> | |
| 46 | + <div class="flex items-center justify-between"> | |
| 47 | + <span class="text-sm text-gray-400">Checkins</span> | |
| 48 | + <span class="text-sm font-medium text-gray-200">{{ stats.checkin_count|def class="text-xs t> | |
| 49 | + <span class="text-sm text-gray-400">Wiki edits</span> | |
| 50 | + <span class="text-sm font-medium text-gray-200">{{ stats.wiki_events|default:"0" }}</span> | |
| 51 | + </div> | |
| 52 | + <div class="flex items-center justify-between"> | |
| 53 | + <span class="text-sm text-gray-400">Ticket changes</span> | |
| 54 | + <span class="text-sm font-medium text-gray-200">{{ stats.ticket_events|default:"0" }}</span> | |
| 55 | + </div> | |
| 56 | + <div class="flex items-center justify-between"> | |
| 57 | + <span class="text-sm text-gray-400">Forum posts</span> | |
| 58 | + <span class="text-sm font-medium text-gray-200">{{ stats.forum_events|default:"0" }}</span> | |
| 59 | + </div> | |
| 60 | + {% if stats.first_checkin %} | |
| 61 | + <div class="flex items-center justify-between pt-2 border-t border-gray-700"> | |
| 62 | + <span class="text-sm text-gray-500">First checkin</span> | |
| 63 | + <span class="text-sm text-gray-400">{{ stats.first_checkin|date:"Y-m-d" }}</span> | |
| 64 | + </div> | |
| 65 | + {% endif %} | |
| 66 | + {% if stats.last_checkin %} | |
| 67 | + <div class="flex items-center justify-between"> | |
| 68 | + <span class="text-sm text-gray-500">Last checkin</span> | |
| 69 | + <span class="text-sm text-gray-400">{{ stats.last_checkin|date:"Y-m-d" }}</span> | |
| 70 | + </div> | |
| 71 | + {% endif %} | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + | |
| 75 | + <!-- Top contributors --> | |
| 76 | + <div class="rounded |
| --- a/templates/fossil/repo_stats.html | |
| +++ b/templates/fossil/repo_stats.html | |
| @@ -0,0 +1,76 @@ | |
| --- a/templates/fossil/repo_stats.html | |
| +++ b/templates/fossil/repo_stats.html | |
| @@ -0,0 +1,76 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Statistics — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block extra_head %} |
| 6 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
| 7 | {% endblock %} |
| 8 | |
| 9 | {% block content %} |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 11 | {% include "fossil/_project_nav.html" %} |
| 12 | |
| 13 | <div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6"> |
| 14 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 15 | <div class="text-2xl font-bold text-gray-100">{{ stats.checkin_count|default:"0" }}</div> |
| 16 | <div class="text-xs text-gray-500 mt-1">Checkins</div> |
| 17 | </div> |
| 18 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 19 | <div class="text-2xl font-bold text-gray-100">{{ stats.contributors|default:"0" }}</div> |
| 20 | <div class="text-xs text-gray-500 mt-1">Contributors</div> |
| 21 | </div> |
| 22 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 23 | <div class="text-2xl font-bold text-gray-100">{{ stats.total_artifacts|default:"0" }}</div> |
| 24 | <div class="text-xs text-gray-500 mt-1">Artifacts</div> |
| 25 | </div> |
| 26 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 27 | <div class="text-2xl font-bold text-gray-100">{{ stats.db_size_mb|default:"0" }} MB</div> |
| 28 | <div class="text-xs text-gray-500 mt-1">Repository Size</div> |
| 29 | </div> |
| 30 | </div> |
| 31 | |
| 32 | {% if activity_json %} |
| 33 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6"> |
| 34 | <h3 class="text-sm font-medium text-gray-300 mb-3">Commit Activity (52 weeks)</h3> |
| 35 | <div style="height: 160px;"> |
| 36 | <canvas id="statsChart"></canvas> |
| 37 | </div> |
| 38 | </div> |
| 39 | {% endif %} |
| 40 | |
| 41 | <div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> |
| 42 | <!-- Event breakdown --> |
| 43 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 44 | <h3 class="text-sm font-medium text-gray-300 mb-3">Event Breakdown</h3> |
| 45 | <div class="space-y-2"> |
| 46 | <div class="flex items-center justify-between"> |
| 47 | <span class="text-sm text-gray-400">Checkins</span> |
| 48 | <span class="text-sm font-medium text-gray-200">{{ stats.checkin_count|def class="text-xs t> |
| 49 | <span class="text-sm text-gray-400">Wiki edits</span> |
| 50 | <span class="text-sm font-medium text-gray-200">{{ stats.wiki_events|default:"0" }}</span> |
| 51 | </div> |
| 52 | <div class="flex items-center justify-between"> |
| 53 | <span class="text-sm text-gray-400">Ticket changes</span> |
| 54 | <span class="text-sm font-medium text-gray-200">{{ stats.ticket_events|default:"0" }}</span> |
| 55 | </div> |
| 56 | <div class="flex items-center justify-between"> |
| 57 | <span class="text-sm text-gray-400">Forum posts</span> |
| 58 | <span class="text-sm font-medium text-gray-200">{{ stats.forum_events|default:"0" }}</span> |
| 59 | </div> |
| 60 | {% if stats.first_checkin %} |
| 61 | <div class="flex items-center justify-between pt-2 border-t border-gray-700"> |
| 62 | <span class="text-sm text-gray-500">First checkin</span> |
| 63 | <span class="text-sm text-gray-400">{{ stats.first_checkin|date:"Y-m-d" }}</span> |
| 64 | </div> |
| 65 | {% endif %} |
| 66 | {% if stats.last_checkin %} |
| 67 | <div class="flex items-center justify-between"> |
| 68 | <span class="text-sm text-gray-500">Last checkin</span> |
| 69 | <span class="text-sm text-gray-400">{{ stats.last_checkin|date:"Y-m-d" }}</span> |
| 70 | </div> |
| 71 | {% endif %} |
| 72 | </div> |
| 73 | </div> |
| 74 | |
| 75 | <!-- Top contributors --> |
| 76 | <div class="rounded |
| --- a/templates/fossil/tag_list.html | ||
| +++ b/templates/fossil/tag_list.html | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load fossil_filters %} | |
| 3 | +{% block title %}Tags — {{ project.name }} — Fossilrepo{% endblock %} | |
| 4 | + | |
| 5 | +{% block content %} | |
| 6 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 7 | +> | |
| 8 | + </div> | |
| 9 | +</div> | |
| 10 | + | |
| 11 | +<div id="tag-content"> | |
| 12 | +<divNo tags.</tdcontent" | |
| 13 | + hxendblock %} |
| --- a/templates/fossil/tag_list.html | |
| +++ b/templates/fossil/tag_list.html | |
| @@ -0,0 +1,13 @@ | |
| --- a/templates/fossil/tag_list.html | |
| +++ b/templates/fossil/tag_list.html | |
| @@ -0,0 +1,13 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Tags — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | > |
| 8 | </div> |
| 9 | </div> |
| 10 | |
| 11 | <div id="tag-content"> |
| 12 | <divNo tags.</tdcontent" |
| 13 | hxendblock %} |