Skip to content

feat: add release calendar export #415

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ django-select2==8.4.0
django-simple-history==3.8.0
django-widget-tweaks==1.5.0
gunicorn==23.0.0
icalendar==6.1.3
Pillow==11.1.0
psycopg[binary]==3.2.6
python-decouple==3.8
Expand Down
1 change: 1 addition & 0 deletions src/events/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
urlpatterns = [
path("calendar", views.release_calendar, name="release_calendar"),
path("reload_calendar", views.reload_calendar, name="reload_calendar"),
path("calendar/download/<str:token>", views.download_calendar, name="download_calendar"),
]
47 changes: 47 additions & 0 deletions src/events/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
import logging
from datetime import date, timedelta

import icalendar
from django.contrib import messages
from django.contrib.auth.decorators import login_not_required
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_POST

from events import tasks
from events.models import Event
from users.models import User

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -96,3 +102,44 @@ def reload_calendar(request):
tasks.reload_calendar.delay(request.user)
messages.info(request, "The task to refresh upcoming releases has been queued.")
return redirect("release_calendar")


@login_not_required
@csrf_exempt
@require_GET
def download_calendar(_, token: str):
"""Download the calendar as a iCalendar file."""

try:
user = User.objects.get(token=token)
except ObjectDoesNotExist:
logger.warning(
"Could not process Calendar request: Invalid token: %s",
token,
)
return HttpResponse(status=401)

# Define default start and end date (from past 30 days to incoming 90 days)
start_date = timezone.now().date() - timedelta(days=30)
end_date = timezone.now().date() + timedelta(days=90)

# Retrieve release events
releases = Event.objects.get_user_events(user, start_date, end_date)

# Create iCalendar object
cal = icalendar.Calendar()
cal.add("prodid", "-//Yamtrack//EN")
cal.add("version", "2.0")

for release in releases:
cal_event = icalendar.Event()
cal_event.add("uid", release.id)
cal_event.add("summary", str(release))
cal_event.add("dtstart", release.datetime.date())
cal_event.add("dtend", release.datetime.date() + timedelta(days=1))
cal.add_component(cal_event)

# Return the iCal file
response = HttpResponse(cal.to_ical(), content_type="text/calendar")
response["Content-Disposition"] = f'attachment; filename="release_calendar.ics"'
return response
44 changes: 44 additions & 0 deletions src/templates/events/calendar.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ <h2 class="px-4 text-lg font-semibold min-w-[160px] text-center">{{ month_name }
</svg>
</a>
</div>

<a class="p-3 text-sm bg-[#39404b] hover:bg-[#454d5a] rounded-md transition-colors"
href="{% url 'download_calendar' token=user.token %}">
<svg xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-4 h-4">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</a>
</div>
</div>

Expand All @@ -120,4 +138,30 @@ <h2 class="px-4 text-lg font-semibold min-w-[160px] text-center">{{ month_name }
{% include "events/components/calendar_list.html" %}
{% endif %}
</div>

<div class="bg-[#2a2f35] p-5 rounded-lg mb-6">
<div class="flex items-center mb-3">
<svg xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-indigo-400 mr-2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
<h3 class="text-base font-medium">Export link</h3>
</div>
<p class="text-gray-400 mb-4 text-sm">This link can be used to integrate your release calendar into any of your calendar applications.</p>
<div class="bg-[#262a2f] p-3 rounded-md mb-4">
<input readonly=""
class="w-full py-2 px-3 bg-[#262a2f] rounded-md text-gray-300 text-sm border-0 focus:ring-0 outline-none font-mono"
type="text"
value="{{ request.scheme }}://{{ request.get_host }}{% url 'download_calendar' token=user.token %}">
</div>
</div>
{% endblock content %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.1.7 on 2025-03-16 13:58

import users.models
from django.db import migrations
from django.db.models import CharField, Q


def fill_missing_tokens(apps, _):
User = apps.get_model("users", "User")
for user in User.objects.filter(
Q(token__isnull=True) | Q(token__exact="")
).iterator():
user.regenerate_token()


class Migration(migrations.Migration):

dependencies = [
("users", "0030_user_notification_excluded_items"),
]

operations = [
migrations.RunPython(fill_missing_tokens),
migrations.AlterField(
model_name="user",
name="token",
field=CharField(
default=users.models.generate_token,
help_text="Token for external webhooks",
max_length=32,
unique=True,
),
),
]
14 changes: 12 additions & 2 deletions src/users/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import secrets

from django.contrib.auth.models import AbstractUser
from django.db import models
from django_celery_beat.models import PeriodicTask
Expand All @@ -13,6 +15,11 @@
]


def generate_token():
"""Generate a user token."""
return secrets.token_urlsafe(24)


class HomeSortChoices(models.TextChoices):
"""Choices for home page sort options."""

Expand Down Expand Up @@ -262,9 +269,8 @@ class User(AbstractUser):

token = models.CharField(
max_length=32,
null=True,
blank=True,
unique=True,
default=generate_token,
help_text="Token for external webhooks",
)

Expand Down Expand Up @@ -510,3 +516,7 @@ def get_import_tasks(self):
"results": results,
"schedules": schedules,
}

def regenerate_token(self):
self.token = generate_token()
self.save(update_fields=["token"])
3 changes: 1 addition & 2 deletions src/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,7 @@ def regenerate_token(request):
"""Regenerate the token for the user."""
while True:
try:
request.user.token = secrets.token_urlsafe(24)
request.user.save(update_fields=["token"])
request.user.regenerate_token()
messages.success(request, "Token regenerated successfully.")
break
except IntegrityError:
Expand Down