Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9e3658d
feat(teams): publish a team's public page at /community/<slug>
harshtandiya Oct 6, 2026
daa6cd9
feat(teams): map, country chips and calendar on team pages
harshtandiya Oct 6, 2026
b152de0
feat(teams): communities list approved events from other teams
harshtandiya Oct 6, 2026
e619f52
feat(teams): co-hosts, registrations-closed badge and team links
harshtandiya Oct 6, 2026
56b47c8
style(teams): tune the team page map's dots and background
harshtandiya Oct 6, 2026
da42377
fix(teams): geocode street addresses from their locality when Nominat…
harshtandiya Oct 6, 2026
86a428e
fix(teams): call the map's whole-view button Everywhere
harshtandiya Oct 6, 2026
64cd5c7
refactor(teams): drop two hand-rolled helpers
harshtandiya Oct 6, 2026
34a23d5
feat(teams): Live and Attending on team page cards
harshtandiya Oct 6, 2026
7ab919a
fix(teams): map tooltip follows the hovered card; Everywhere frames w…
harshtandiya Oct 6, 2026
be7e99d
style(teams): wider timeline, team logo sized like frappe-ui's avatar
harshtandiya Oct 6, 2026
30ac254
refactor(teams): render the card's time row without a dead check
harshtandiya Oct 6, 2026
34ced82
Merge develop into feat/team-pages
harshtandiya Oct 6, 2026
3e7003d
refactor(teams): leave the community review page for later
harshtandiya Oct 6, 2026
3b06360
test(teams): stub assets.json in team page render tests
harshtandiya Oct 6, 2026
85e9e14
test(teams): drop route tests the render tests already cover
harshtandiya Oct 6, 2026
e8822c6
fix(communities): escape user-written text in community emails
harshtandiya Oct 6, 2026
fe8c1fa
fix(communities): let a curator remove an approved event after it is …
harshtandiya Oct 6, 2026
5120127
fix(venues): geocode on a longitude change; backfill venues in chaine…
harshtandiya Oct 6, 2026
f8dab79
fix(teams): keep the links hint translatable
harshtandiya Oct 6, 2026
f421ec1
perf(teams): serve the map's land dots as a cacheable SVG
harshtandiya Oct 6, 2026
9337198
refactor(communities): hide event submission until communities can re…
harshtandiya Oct 6, 2026
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
60 changes: 60 additions & 0 deletions buzz/api/communities/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import frappe

from buzz.api.communities.reviews import CommunityReview
from buzz.api.communities.schemas import CommunityQueue, EventOption, EventRequests
from buzz.api.communities.submissions import EventSubmissions, submittable_events


@frappe.whitelist()
def get_event_requests(event: str) -> EventRequests:
return EventSubmissions(event).overview()


@frappe.whitelist()
def get_submittable_events(community: str) -> list[EventOption]:
return submittable_events(community)


@frappe.whitelist(methods=["POST"])
def submit_event(event: str, community: str) -> None:
EventSubmissions(event).submit(community)


@frappe.whitelist(methods=["POST"])
def resubmit_request(request: str) -> None:
submissions, doc = EventSubmissions.from_request(request)
submissions.resubmit(doc)


@frappe.whitelist(methods=["POST"])
def withdraw_request(request: str) -> None:
submissions, doc = EventSubmissions.from_request(request)
submissions.withdraw(doc)


@frappe.whitelist()
def get_requests(community: str) -> CommunityQueue:
return CommunityReview(community).queue()


@frappe.whitelist(methods=["POST"])
def approve_request(request: str) -> None:
community_review, doc = CommunityReview.from_request(request)
community_review.review(doc, "Approved")


@frappe.whitelist(methods=["POST"])
def reject_request(request: str, note: str | None = None) -> None:
community_review, doc = CommunityReview.from_request(request)
community_review.review(doc, "Rejected", note)


@frappe.whitelist(methods=["POST"])
def remove_event(request: str) -> None:
community_review, doc = CommunityReview.from_request(request)
community_review.remove(doc)


@frappe.whitelist(methods=["POST"])
def add_event(community: str, event: str) -> None:
CommunityReview(community).add(event)
23 changes: 23 additions & 0 deletions buzz/api/communities/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from frappe import _lt

from buzz.api.exceptions import Conflict, NotPermitted


class CannotSubmitEvent(NotPermitted):
message = _lt("Only the event team's owners, admins and managers can send it to a community.")


class CannotReviewRequests(NotPermitted):
message = _lt("Only the community's owners, admins and managers can review its events.")


class RequestNotPending(Conflict):
message = _lt("This request has been reviewed already.")


class RequestNotRejected(Conflict):
message = _lt("Only a rejected request can be submitted again.")


class RequestNotApproved(Conflict):
message = _lt("Only an approved event can be removed from the community.")
54 changes: 54 additions & 0 deletions buzz/api/communities/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import frappe
from frappe import _
from frappe.utils import get_url

from buzz.permissions import WRITE_ROLES


def community_managers(community: str) -> list[str]:
filters = {"team": community, "enabled": 1, "team_role": ["in", list(WRITE_ROLES)]}
return frappe.get_all("Buzz Team Membership", filters=filters, pluck="user")


def team_name(team: str) -> str:
return frappe.db.get_value("Buzz Team", team, "team_name")


def send_email(request, recipients: list[str], subject: str, template: str, **args) -> None:
frappe.sendmail(
recipients=recipients,
subject=subject,
template=template,
raw_html=True,
add_css=False,
args={"event_title": request.event_title, "community_name": team_name(request.community), **args},
reference_doctype=request.doctype,
reference_name=request.name,
)


def notify_submitted(request) -> None:
submitting_team = team_name(request.event_team)
send_email(
request,
community_managers(request.community),
_("{0} submitted {1}").format(submitting_team, request.event_title),
"community_event_submitted",
team_name=submitting_team,
)


def notify_reviewed(request) -> None:
# A request made in Desk names no submitter, so there is nobody to tell.
if not request.submitted_by:
return
approved = request.status == "Approved"
community_route = frappe.db.get_value("Buzz Team", request.community, "route")
send_email(
request,
[request.submitted_by],
(_("{0} was approved") if approved else _("{0} was not approved")).format(request.event_title),
"community_event_approved" if approved else "community_event_rejected",
community_url=get_url(f"/{community_route}"),
review_note=request.review_note,
)
62 changes: 62 additions & 0 deletions buzz/api/communities/queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import frappe

from buzz.api.communities.schemas import CommunityRequest, EventOption
from buzz.www.events import TIME_FIELDS, exclude_ended_events, upcoming_filters

EVENT_OPTION_LIMIT = 20


def request_rows(**conditions) -> list[CommunityRequest]:
"""Requests matching `conditions`, each with its event and both teams spelled out."""
request, event, user = (
frappe.qb.DocType(name) for name in ("Community Event Request", "Buzz Event", "User")
)
event_team, community = frappe.qb.DocType("Buzz Team").as_("event_team"), frappe.qb.DocType("Buzz Team")
query = (
frappe.qb.from_(request)
.join(event)
.on(event.name == request.event)
.join(event_team)
.on(event_team.name == request.event_team)
.join(community)
.on(community.name == request.community)
.left_join(user)
.on(user.name == request.submitted_by)
.select(
request.name,
request.event,
event.title.as_("event_title"),
event.route.as_("event_route"),
event.start_date,
request.event_team,
event_team.team_name.as_("event_team_name"),
event_team.logo.as_("event_team_logo"),
request.community,
community.team_name.as_("community_name"),
request.status,
request.submitted_by,
user.full_name.as_("submitter_name"),
request.review_note,
)
.orderby(event.start_date)
)
for field, value in conditions.items():
query = query.where(request[field] == value)
return [CommunityRequest(**row) for row in query.run(as_dict=True)]


def upcoming_event_options(filters: dict) -> list[EventOption]:
"""Published events that have not ended, as picker options."""
events = frappe.get_all(
"Buzz Event",
filters=upcoming_filters() | filters,
fields=["name", "title", "start_date", "team.team_name as team_name", *TIME_FIELDS],
order_by="start_date asc",
limit=EVENT_OPTION_LIMIT,
)
return [
EventOption(
name=str(event.name), title=event.title, start_date=event.start_date, team_name=event.team_name
)
for event in exclude_ended_events(events)
]
52 changes: 52 additions & 0 deletions buzz/api/communities/reviews.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import frappe

from buzz.api.communities.exceptions import CannotReviewRequests, RequestNotApproved, RequestNotPending
from buzz.api.communities.notifications import notify_reviewed
from buzz.api.communities.queries import request_rows
from buzz.api.communities.schemas import CommunityQueue
from buzz.permissions import has_team_access

REQUEST = "Community Event Request"


class CommunityReview:
"""A community's queue: approve, reject or remove events from other teams, or add them."""

def __init__(self, community: str):
if not community or not has_team_access(community, "write", frappe.session.user):
CannotReviewRequests.throw()
self.community = community

@classmethod
def from_request(cls, request: str) -> tuple["CommunityReview", "frappe.model.document.Document"]:
doc = frappe.get_doc(REQUEST, request)
return cls(doc.community), doc

def queue(self) -> CommunityQueue:
return CommunityQueue(
pending=request_rows(community=self.community, status="Pending"),
approved=request_rows(community=self.community, status="Approved"),
)

def review(self, request, status: str, note: str | None = None) -> None:
if request.status != "Pending":
RequestNotPending.throw()
self.set_status(request, status, note)
notify_reviewed(request)

def remove(self, request) -> None:
if request.status != "Approved":
RequestNotApproved.throw()
self.set_status(request, "Rejected")

def add(self, event: str) -> None:
"""Lists an event straight away, reusing the event's earlier request if it has one."""
name = frappe.db.get_value(REQUEST, {"event": event, "community": self.community})
request = frappe.get_doc(REQUEST, name) if name else frappe.new_doc(REQUEST)
request.update({"event": event, "community": self.community})
request.submitted_by = request.submitted_by or frappe.session.user
self.set_status(request, "Approved")

def set_status(self, request, status: str, note: str | None = None) -> None:
request.update({"status": status, "reviewed_by": frappe.session.user, "review_note": note})
request.save(ignore_permissions=True)
44 changes: 44 additions & 0 deletions buzz/api/communities/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from datetime import date

from buzz.api.schemas import APIResponse


class CommunityOption(APIResponse):
name: str
team_name: str
logo: str | None


class EventOption(APIResponse):
name: str
title: str
start_date: date
team_name: str


class CommunityRequest(APIResponse):
name: str
event: str
event_title: str
event_route: str | None
start_date: date
event_team: str
event_team_name: str
event_team_logo: str | None
community: str
community_name: str
status: str
submitted_by: str | None
submitter_name: str | None
review_note: str | None


class EventRequests(APIResponse):
requests: list[CommunityRequest]
# Published communities this event has no request with yet.
communities: list[CommunityOption]


class CommunityQueue(APIResponse):
pending: list[CommunityRequest]
approved: list[CommunityRequest]
79 changes: 79 additions & 0 deletions buzz/api/communities/submissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import frappe

from buzz.api.communities.exceptions import CannotSubmitEvent, RequestNotRejected
from buzz.api.communities.notifications import notify_submitted
from buzz.api.communities.queries import request_rows, upcoming_event_options
from buzz.api.communities.schemas import CommunityOption, EventOption, EventRequests
from buzz.permissions import WRITE_ROLES, has_team_access

REQUEST = "Community Event Request"


class EventSubmissions:
"""One event's requests to communities, made by the event's own team."""

def __init__(self, event: str):
self.event = str(event)
self.team = frappe.db.get_value("Buzz Event", self.event, "team")
if not self.team or not has_team_access(self.team, "write", frappe.session.user):
CannotSubmitEvent.throw()

@classmethod
def from_request(cls, request: str) -> tuple["EventSubmissions", "frappe.model.document.Document"]:
doc = frappe.get_doc(REQUEST, request)
return cls(doc.event), doc

def overview(self) -> EventRequests:
return EventRequests(requests=request_rows(event=self.event), communities=self.open_communities())

def open_communities(self) -> list[CommunityOption]:
asked = frappe.get_all(REQUEST, filters={"event": self.event}, pluck="community")
filters = {"is_a_community": 1, "is_published": 1, "name": ["not in", [*asked, self.team]]}
rows = frappe.get_all(
"Buzz Team", filters=filters, fields=["name", "team_name", "logo"], order_by="team_name"
)
return [CommunityOption(**row) for row in rows]

def submit(self, community: str) -> None:
request = frappe.get_doc(
{
"doctype": REQUEST,
"event": self.event,
"community": community,
"submitted_by": frappe.session.user,
}
)
request.insert(ignore_permissions=True)
notify_submitted(request)

def resubmit(self, request) -> None:
if request.status != "Rejected":
RequestNotRejected.throw()
request.update(
{
"status": "Pending",
"submitted_by": frappe.session.user,
"reviewed_by": None,
"review_note": None,
}
)
request.save(ignore_permissions=True)
notify_submitted(request)

def withdraw(self, request) -> None:
request.delete(ignore_permissions=True)


def submittable_events(community: str) -> list[EventOption]:
"""The session user's upcoming events this community has not been asked about."""
membership = {
"user": frappe.session.user,
"enabled": 1,
"team_role": ["in", list(WRITE_ROLES)],
"team": ["!=", community],
}
teams = frappe.get_all("Buzz Team Membership", filters=membership, pluck="team")
if not teams:
return []
asked = frappe.get_all(REQUEST, filters={"community": community}, pluck="event")
return upcoming_event_options({"team": ["in", teams], "name": ["not in", asked]})
Loading
Loading