Skip to content

Commit ea879fa

Browse files
authored
Merge pull request #216 from canonical/discourse-community-portal-improvements
Feature branch - Discourse community portal improvements
2 parents f6dff22 + 3bdb5cd commit ea879fa

File tree

11 files changed

+575
-62
lines changed

11 files changed

+575
-62
lines changed

CHANGELOG.md

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,47 @@
1+
### 7.0.0 [01-07-2025]
2+
**Added** Events class
3+
- Created a new class to handle events from 'Discourse Calender (and events)' API
4+
**Added** EventsParser class
5+
- Created a new parser to process the events retrieve by the Events class
6+
**Updated** check_for_category_updates & check_for_topic_updates
7+
- Moved from Category into DiscourseAPI
8+
**Updated** Category class
9+
- The function `get_topics_in_category` now returns an array of objects
10+
11+
### 6.5.0 [12-06-2025]
12+
**Updated** Discourse API
13+
- Created a new API to query upcoming events in a category
14+
**Updated** Category class
15+
- Exposes the events API on the Category class
16+
17+
### 6.4.0 [10-06-2025]
18+
**Updated** CategoryParser
19+
- Will split a link found in metadata tables into its href ('url') and text content ('text'), as properties on the table row item.
20+
- Handles tables positioned below the string [details=NAME] and nested in a `<details>` element.
21+
**Updated** test_parser.py
22+
- Added tests for the Category parser
23+
124
### 6.3.0 [02-07-2025]
225
**Updated** EngagePages class
3-
Remove duplicated tags from the list of tags returned from `get_engage_pages_tags`
26+
- Remove duplicated tags from the list of tags returned from `get_engage_pages_tags`
427

528
### 6.2.0 [28-04-2025]
629
**Added** _inject_custom_css def
7-
A function that finds css directives (`[style=CLASSNAME]`) in the soup and applies to them to the next found element.
30+
- A function that finds css directives (`[style=CLASSNAME]`) in the soup and applies to them to the next found element.
831

932
### 6.1.1 [12-03-2025]
1033
**Updated** EngagePages class
11-
Pass values for the provided keys, even if the values are empty or null, as they can be a filter themselves.
34+
- Pass values for the provided keys, even if the values are empty or null, as they can be a filter themselves.
1235

1336
### 6.1.0 [25-01-2025]
1437
**Updated** Category class
15-
Check for additions or deletions of topics within a category and update cached data if needed
38+
- Check for additions or deletions of topics within a category and update cached data if needed
1639

1740
### 6.0.0 [28-01-2025]
1841
**Updated** Category class
19-
Removed to template handling from within the Category class.
20-
Bump to 6.0.0, as the previous update was a major
42+
- Removed to template handling from within the Category class.
43+
- Bump to 6.0.0, as the previous update was a major
2144

2245
### 5.8.0 [28-01-2025]
2346
**Added** Category class
24-
A generic class for processing discourse categories and the topics they contain
47+
- A generic class for processing discourse categories and the topics they contain

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,54 @@ Similarly for takeovers, you just need to pass `page_type="takeovers"`.
9898
- `get_index` provides two additional arguments `limit` and `offset`, to provide pagination functionality. They default to 50 and 0 respectively.
9999
- If you want to get all engage pages, which in the case of some sites like jp.ubuntu.com there are not that many, you can pass `limit=-1`
100100
- Use `MaxLimitError` in the `exceptions.py` to handle excessive limit. By default, it will raise an error when it surpasses 500
101+
102+
103+
## Instructions for Category class usage
104+
105+
This works similar to the other class but exposes some specific functions that can be run on the index topic and the category as a whole.
106+
107+
It exposes a some APIs that can then be called from within a view func for processing.
108+
109+
Here is an example of the implementation:
110+
111+
```
112+
security_vulnerabilities = Category(
113+
parser=CategoryParser(
114+
api=discourse_api,
115+
index_topic_id=53193,
116+
url_prefix="/security/vulnerabilities",
117+
),
118+
category_id=308,
119+
)
120+
```
121+
122+
The `security_vulnerabilities` object exposes the following APIs:
123+
124+
- get_topic(path): Fetches a single topic using its URL (path).
125+
- get_category_index_metadata(data_name): Retrieves metadata for the category index. You can optionally specify a data_name to get data for just one table.
126+
- get_topics_in_category(): Retrieves all topics within the currently active category.
127+
- get_category_events(limit=100, offset=0): Retrieves all future events in a category. Requires the Discourse Events plugin to be installed on the instance.
128+
129+
## Instructions for Events class usage
130+
131+
This class provides functionality for managing and parsing events from Discourse topics, particularly useful for event-driven websites that need to display upcoming events, featured events, and event categories. It relies on the plugin, [Discourse Calendar](https://meta.discourse.org/t/discourse-calendar-and-event/97376).
132+
133+
It exposes APIs that can be called from within a view function for processing event data.
134+
135+
Here is an example of the implementation:
136+
137+
```python
138+
events = Events(
139+
parser=EventsParser(
140+
api=discourse_api,
141+
index_topic_id=12345,
142+
url_prefix="/events",
143+
),
144+
category_id=25,
145+
)
146+
```
147+
148+
The `events` object exposes the following APIs:
149+
150+
- get_events(): Fetches all future events from the target Discourse instance.
151+
- get_featured_events(target_tag="featured-event"): Retrieves all events with a given tagrte tag, defaults to "featured-event"

canonicalwebteam/discourse/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
EngagePages, # noqa
44
Tutorials, # noqa
55
Category, # noqa
6+
Events, # noqa
67
)
78
from canonicalwebteam.discourse.models import DiscourseAPI # noqa
89
from canonicalwebteam.discourse.parsers import ( # noqa
910
DocParser, # noqa
1011
TutorialParser, # noqa
1112
CategoryParser, # noqa
13+
EventsParser, # noqa
1214
)

canonicalwebteam/discourse/app.py

Lines changed: 79 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,6 @@ def __init__(
306306
self.page_type = page_type
307307
self.exclude_topics = exclude_topics
308308
self.additional_metadata_validation = additional_metadata_validation
309-
pass
310309

311310
def get_index(
312311
self,
@@ -729,15 +728,18 @@ def __init__(
729728
self.index_last_updated = None
730729
self.category_last_updated = None
731730
self.category_index_metadata = None
731+
self.events = None
732732
pass
733733

734-
def get_topic(self, path=""):
734+
def get_topic(self, path="/"):
735735
"""
736736
A Flask view function to serve topics from a Discourse category
737737
"""
738-
path = "/" + path
738+
if path != "/" and not path.startswith("/"):
739+
path = "/" + path
740+
739741
if path == "/":
740-
document = self.parser.parse_topic(self.parser.index_topic)
742+
topic = self.parser.api.get_topic(self.parser.index_topic_id)
741743
else:
742744
try:
743745
topic_id = self._get_topic_id_from_path(path)
@@ -749,15 +751,28 @@ def get_topic(self, path=""):
749751
except HTTPError as http_error:
750752
return flask.abort(http_error.response.status_code)
751753

752-
document = self.parser.parse_topic(topic)
754+
document = self.parser.parse_topic(topic)
755+
756+
return document
757+
758+
def get_topic_by_id(self, topic_id):
759+
"""
760+
A Flask view function to serve a topic by its ID
761+
"""
762+
try:
763+
topic = self.parser.api.get_topic(topic_id)
764+
except HTTPError as http_error:
765+
return flask.abort(http_error.response.status_code)
766+
767+
document = self.parser.parse_topic(topic)
753768

754769
return document
755770

756771
def _get_topic_id_from_path(self, path):
757772
path = path.lstrip("/")
758-
for topic in self.get_topics_in_category().items():
759-
if topic[1] == path:
760-
return topic[0]
773+
for topic in self.get_topics_in_category():
774+
if topic["slug"] == path:
775+
return topic["id"]
761776
return None
762777

763778
def get_category_index_metadata(self, data_name=""):
@@ -769,8 +784,8 @@ def get_category_index_metadata(self, data_name=""):
769784
:param data_name: Name of the data table
770785
"""
771786
try:
772-
updated, updated_at = self._check_for_topic_updates(
773-
self.parser.index_topic_id
787+
updated, updated_at = self.parser.api.check_for_topic_updates(
788+
self.parser.index_topic_id, self.index_last_updated
774789
)
775790

776791
if self.category_index_metadata is None or updated:
@@ -791,50 +806,73 @@ def get_topics_in_category(self):
791806
Exposes an API to query all topics in a category
792807
"""
793808
try:
794-
updated, updated_at = self._check_for_category_updates(
795-
self.category_id
809+
updated, updated_at = self.parser.api.check_for_category_updates(
810+
self.category_id, self.category_last_updated
796811
)
797812

798813
if self.category_topics is None or updated:
799-
self.category_topics = (
800-
self.parser.api.get_topic_list_by_category(
801-
self.category_id
802-
)
814+
all_topics = self.parser.api.get_topic_list_by_category(
815+
self.category_id
803816
)
817+
# Filter out excluded topics
818+
self.category_topics = [
819+
topic
820+
for topic in all_topics
821+
if topic.get("id") not in self.exclude_topics
822+
]
804823
self.category_last_updated = updated_at
805824

806825
except Exception:
807826
if self.category_topics is None:
808827
return {}
809828

810-
return {str(topic[0]): topic[2] for topic in self.category_topics}
829+
return self.category_topics
830+
831+
832+
class Events:
833+
"""
834+
A class to handle events in a Discourse category.
835+
It intergrates with the Discourse Events plugin.
836+
837+
:param parser: A data parser class
838+
:param category_id: ID of a Discourse category
839+
"""
840+
841+
def __init__(self, parser, category_id):
842+
self.parser = parser
843+
self.category_id = category_id
844+
self.all_events = None
845+
self.events_last_updated = None
846+
self.featured_events = None
847+
pass
811848

812-
def _check_for_topic_updates(self, topic_id):
849+
def get_events(self) -> list:
813850
"""
814-
Check if the index topic has been updated
851+
Fetches all future events from the target Discourse instance.
815852
"""
816-
most_recent_update = self.parser.api.get_topics_last_activity_time(
817-
topic_id
818-
)[0][1]
819-
if (
820-
self.index_last_updated
821-
and most_recent_update > self.index_last_updated
822-
):
823-
return True, most_recent_update
824-
else:
825-
return False, most_recent_update
853+
updated, updated_at = self.parser.api.check_for_category_updates(
854+
self.category_id, self.events_last_updated
855+
)
826856

827-
def _check_for_category_updates(self, category_id):
857+
if self.all_events is None or updated:
858+
self.all_events = self.parser.api.get_events()["events"]
859+
self.events_last_updated = updated_at
860+
861+
return self.all_events
862+
863+
def get_featured_events(self, target_tag="featured-event") -> list:
828864
"""
829-
Check if the category has had topics added or removed
865+
Fetches events that are marked with a specific tag.
866+
867+
:param target_tag: Tag to filter featured events by.
830868
"""
831-
most_recent_update = self.parser.api.get_categories_last_activity_time(
832-
category_id
833-
)[0][1]
834-
if (
835-
self.category_last_updated
836-
and most_recent_update > self.category_last_updated
837-
):
838-
return True, most_recent_update
839-
else:
840-
return False, most_recent_update
869+
featured_events_ids = self.parser.api.get_topics_by_tag(target_tag)[
870+
"grouped_search_result"
871+
]["post_ids"]
872+
all_events = self.get_events()
873+
874+
self.featured_events = self.parser.parse_featured_events(
875+
all_events, featured_events_ids
876+
)
877+
878+
return self.featured_events

canonicalwebteam/discourse/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ def __init__(self, *args: object) -> None:
7777
pass
7878

7979

80+
class DiscourseEventsError(Exception):
81+
"""
82+
Error for the Discourse Events plugin.
83+
84+
Will be sent to Sentry
85+
"""
86+
87+
def __init__(self, *args: object) -> None:
88+
error_message = args[0]
89+
flask.current_app.extensions["sentry"].captureMessage(
90+
f"Discourse event plugin error {error_message}"
91+
)
92+
pass
93+
94+
8095
class MaxLimitError(Exception):
8196
"""
8297
Error raised when limit/offset is too high

0 commit comments

Comments
 (0)