Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…into update-events-ui
  • Loading branch information
joeriddles committed May 9, 2024
2 parents 3cb2a63 + eecbeba commit 73b561c
Show file tree
Hide file tree
Showing 21 changed files with 437 additions and 49 deletions.
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
asgiref==3.7.2
beautifulsoup4==4.12.3
celery[redis]==5.3.6
crispy-bootstrap5==2024.2
discord.py==2.3.2
dj-database-url==2.1.0
django-celery-beat==2.6.0
django-celery-results==2.5.1
django-crispy-forms==2.1
django-handyhelpers==0.3.22
django-markdownify==0.9.3
django-storages[azure]==1.14.2
Expand Down
8 changes: 8 additions & 0 deletions src/spokanetech/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
"storages",
"django_celery_results",
"django_celery_beat",
"crispy_forms",
"crispy_bootstrap5",
"markdownify.apps.MarkdownifyConfig",
"handyhelpers",
"web",
Expand Down Expand Up @@ -265,3 +267,9 @@
"debug_toolbar.panels.versions.VersionsPanel",
}
}


# Crispy Forms
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"

CRISPY_TEMPLATE_PACK = "bootstrap5"
10 changes: 10 additions & 0 deletions src/static/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@
.link {
text-decoration: 2px solid underline;
}

.container-xs {
max-width: 100%;
}

@media screen and (min-width: 576px) {
.container-xs {
max-width: 600px;
}
}
4 changes: 3 additions & 1 deletion src/templates/spokanetech/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
$(document).ready(() => {
const timezoneID = Intl.DateTimeFormat().resolvedOptions().timeZone;
const storedTimezone = window.localStorage.getItem(TIMEZONE_ID)
if (!storedTimezone || storedTimezone != timezoneID) {
const sessionTimezone = "{{ request.timezone }}"
if (!storedTimezone || storedTimezone != timezoneID || !sessionTimezone || sessionTimezone != timezoneID) {
$.post({
url: "/set_timezone/",
data: { "timezone": timezoneID },
headers: { "X-CSRFToken": csrftoken }, // handyhelpers base template defines `csrftoken`
success: (data) => {
window.localStorage.setItem(TIMEZONE_ID, timezoneID)
location.reload()
},
})
}
Expand Down
8 changes: 7 additions & 1 deletion src/web/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from django.contrib import admin

# import models
from web.models import Event, TechGroup
from web.models import Event, TechGroup, Tag


class TagAdmin(admin.ModelAdmin):
list_display = ["value"]
search_fields = ["value"]


class EventAdmin(admin.ModelAdmin):
Expand Down Expand Up @@ -29,3 +34,4 @@ class TechGroupAdmin(admin.ModelAdmin):
# register models
admin.site.register(Event, EventAdmin)
admin.site.register(TechGroup, TechGroupAdmin)
admin.site.register(Tag, TagAdmin)
49 changes: 49 additions & 0 deletions src/web/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms

from web import models


class DateTimePickerInput(forms.DateTimeInput):
"""DateTime input that uses browser-native calendar widget."""

def __init__(self, attrs=None, format=None):
attrs = attrs or {}
attrs["type"] = "datetime-local"
super().__init__(attrs, format)


class TechGroupForm(forms.ModelForm):
class Meta:
model = models.TechGroup
fields = [
"name",
"description",
"homepage",
"icon",
]

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper(self)
self.helper.form_class = "container-xs"
self.helper.add_input(Submit("save", "Save", css_class="float-end"))


class EventForm(forms.ModelForm):
date_time = forms.DateTimeField(widget=DateTimePickerInput)

class Meta:
model = models.Event
fields = "__all__"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper(self)
self.helper.form_class = "container-xs"
self.helper.add_input(Submit("save", "Save", css_class="float-end"))


class ListEventsFilter(forms.Form):
tags = forms.ModelMultipleChoiceField(queryset=models.Tag.objects.all())
1 change: 1 addition & 0 deletions src/web/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
def __call__(self, request: HttpRequest):
if timezone_id := request.session.get("timezone"):
timezone.activate(zoneinfo.ZoneInfo(timezone_id))
request.timezone = timezone_id # type: ignore

return self.get_response(request)
30 changes: 30 additions & 0 deletions src/web/migrations/0008_tag_event_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 5.0.1 on 2024-04-05 17:46

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('web', '0007_techgroup_icon'),
]

operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('value', models.CharField(max_length=32, unique=True)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='event',
name='tags',
field=models.ManyToManyField(to='web.tag'),
),
]
26 changes: 23 additions & 3 deletions src/web/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
from handyhelpers.models import HandyHelperBaseModel


class Tag(HandyHelperBaseModel):
"""A Tag that describes attributes of a Event."""

value = models.CharField(max_length=32, unique=True, null=False)

class Meta:
ordering = ["value"]

def __str__(self) -> str:
return self.value


class TechGroup(HandyHelperBaseModel):
"""A group that organizes events."""

Expand All @@ -19,6 +31,9 @@ class TechGroup(HandyHelperBaseModel):
help_text="Emojji or Font Awesome CSS icon class(es) to represent the group.",
)

class Meta:
ordering = ["name"]

def __str__(self) -> str:
return self.name

Expand All @@ -29,10 +44,14 @@ def get_absolute_url(self) -> str:
class Event(HandyHelperBaseModel):
"""An event on a specific day and time."""

name = models.CharField(max_length=64, help_text="name of this event")
description = models.TextField(blank=True, null=True, help_text="name of this event")
name = models.CharField(max_length=64)
description = models.TextField(blank=True, null=True)
date_time = models.DateTimeField(auto_now=False, auto_now_add=False, help_text="")
duration = models.DurationField(blank=True, null=True, help_text="planned duration of this event")
duration = models.DurationField(
blank=True,
null=True,
help_text="planned duration of this event",
)
location = models.CharField(
max_length=128,
blank=True,
Expand All @@ -51,6 +70,7 @@ class Event(HandyHelperBaseModel):
help_text="ID field for tracking a unique external event",
)
group = models.ForeignKey(TechGroup, blank=True, null=True, on_delete=models.SET_NULL)
tags = models.ManyToManyField(Tag, blank=True)
# labels = models.ManyToManyField("TechnicalArea")

# class Meta:
Expand Down
38 changes: 25 additions & 13 deletions src/web/scrapers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import re
import urllib.parse
from datetime import datetime, timedelta
from typing import Any, Protocol, TypeVar
from typing import Any, Protocol, TypeAlias, TypeVar

import requests
import zoneinfo
Expand All @@ -21,6 +21,9 @@ def scrape(self, url: str) -> ST:
...


MeetUpEventScraperResult: TypeAlias = tuple[models.Event, list[models.Tag]]


class MeetupScraperMixin:
"""Common Meetup scraping functionality."""

Expand Down Expand Up @@ -84,12 +87,12 @@ def _filter_event_tag(self, event: Tag) -> bool:
return event_datetime > self._now


class MeetupEventScraper(MeetupScraperMixin, Scraper[models.Event]):
class MeetupEventScraper(MeetupScraperMixin, Scraper[MeetUpEventScraperResult]):
"""Scrape an Event from a Meetup details page."""

DURATION_PATTERN = re.compile(r"1?\d:\d{2} [AP]M to 1?\d:\d{2} [AP]M")

def scrape(self, url: str) -> models.Event:
def scrape(self, url: str) -> MeetUpEventScraperResult:
response = requests.get(url, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.content, "lxml")
Expand All @@ -100,7 +103,7 @@ def scrape(self, url: str) -> models.Event:
except LookupError:
event_json = {}

if event_json:
try:
name = event_json["title"]
description = event_json["description"]
date_time = datetime.fromisoformat(event_json["dateTime"])
Expand All @@ -109,22 +112,26 @@ def scrape(self, url: str) -> models.Event:
location_data = apollo_state[event_json["venue"]["__ref"]]
location = f"{location_data['address']}, {location_data['city']}, {location_data['state']}"
external_id = event_json["id"]
else:
except KeyError:
name = self._parse_name(soup)
description = self._parse_description(soup)
date_time = self._parse_date_time(soup)
duration = self._parse_duration(soup)
location = self._parse_location(soup)
external_id = self._parse_external_id(url)

return models.Event(
name=name,
description=description,
date_time=date_time,
duration=duration,
location=location,
external_id=external_id,
url=url,
tags = self._parse_tags(soup)
return (
models.Event(
name=name,
description=description,
date_time=date_time,
duration=duration,
location=location,
external_id=external_id,
url=url,
),
tags,
)

def _parse_name(self, soup: BeautifulSoup) -> str:
Expand Down Expand Up @@ -162,3 +169,8 @@ def _parse_external_id(self, url: str) -> str:
parsed_url = urllib.parse.urlparse(url).path
external_id = pathlib.PurePosixPath(urllib.parse.unquote(parsed_url)).parts[-1]
return external_id

def _parse_tags(self, soup: BeautifulSoup) -> list[models.Tag]:
tags = soup.find_all("a", id=re.compile("topics-link-"))
tags = [re.sub(r"\s+", " ", t.text) for t in tags] # Some tags have newlines & extra spaces
return [models.Tag(value=t) for t in tags]
15 changes: 11 additions & 4 deletions src/web/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,31 @@ class MeetupService:
def __init__(
self,
homepage_scraper: scrapers.Scraper[list[str]] | None = None,
event_scraper: scrapers.Scraper[models.Event] | None = None,
event_scraper: scrapers.Scraper[scrapers.MeetUpEventScraperResult] | None = None,
) -> None:
self.homepage_scraper: scrapers.Scraper[list[str]] = homepage_scraper or scrapers.MeetupHomepageScraper()
self.event_scraper: scrapers.Scraper[models.Event] = event_scraper or scrapers.MeetupEventScraper()
self.event_scraper: scrapers.Scraper[scrapers.MeetUpEventScraperResult] = (
event_scraper or scrapers.MeetupEventScraper()
)

def scrape_events_from_meetup(self) -> None:
"""Scrape upcoming events from Meetup and save them to the database."""
for tech_group in models.TechGroup.objects.filter(homepage__icontains="meetup.com"):
event_urls = self.homepage_scraper.scrape(tech_group.homepage) # type: ignore
for event_url in event_urls: # TODO: parallelize (with async?)
event = self.event_scraper.scrape(event_url)
event, tags = self.event_scraper.scrape(event_url)
event.group = tech_group
defaults = model_to_dict(event, exclude=["id"])
defaults["group"] = tech_group
models.Event.objects.update_or_create(

del defaults["tags"] # Can't apply Many-to-Many relationship untill after the event has been saved.
new_event, _ = models.Event.objects.update_or_create(
external_id=event.external_id,
defaults=defaults,
)
for tag in tags:
tag, _ = models.Tag.objects.get_or_create(value=tag)
new_event.tags.add(tag)


class Sender(Protocol):
Expand Down
7 changes: 7 additions & 0 deletions src/web/templates/web/event_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends 'spokanetech/base.html' %}
{% load crispy_forms_tags %}

{% block content %}
{% if object %}<h1>Edit Event</h1>{% else %}<h1>Add Event</h1>{% endif %}
{% crispy form form.helper %}
{% endblock content %}
14 changes: 14 additions & 0 deletions src/web/templates/web/partials/detail_event.htm
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,23 @@ <h2>Description</h2>
{{ object.description|markdownify }}
</p>

<p>
<h3>Tags</h3>
<ul>
{% for tag in object.tags.all %}
<li><a href="/events?tags={{ tag.id }}&">{{ tag.value }}</a></li>
{% empty %}
<li>None!</li>
{% endfor %}
</ul>
</p>

{% if event.url %}
<a class="btn btn-primary" href="{{ event.url }}" target="_blank">
RSVP <i class="fa-solid fa-arrow-up-right-from-square"></i>
</a>
{% endif %}
{% if can_edit %}
<a href="{% url 'web:update_event' object.pk %}" class="btn btn-primary">Edit</a>
{% endif %}
</div>
4 changes: 4 additions & 0 deletions src/web/templates/web/partials/detail_tech_group.htm
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ <h1>
{% else %}
<i>No description</i>
{% endif %}

{% if can_edit %}
<a href="{% url 'web:edit_tech_group' object.pk %}" class="btn btn-primary">Edit</a>
{% endif %}
</div>
6 changes: 6 additions & 0 deletions src/web/templates/web/partials/table/table_tech_groups.htm
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@
{% endfor %}
</tbody>
</table>

{% if kwargs.can_edit %}
<div class="mt-3">
<a href="{% url 'web:add_tech_group' %}" class="btn btn-primary">Add</a>
</div>
{% endif %}
Loading

0 comments on commit 73b561c

Please sign in to comment.