Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
685c4d6
refactor(teams): rename teams to communities, is_a_community to accep…
harshtandiya Oct 8, 2026
c240d00
refactor(dashboard): move team workspace urls to /manage/communities
harshtandiya Oct 8, 2026
4ef78f1
feat(dashboard): show an opening screen when entering a community wor…
harshtandiya Oct 8, 2026
50cbe74
feat(dashboard): community page button and create event beside the ti…
harshtandiya Oct 8, 2026
f1cef74
feat(dashboard): community submissions page
harshtandiya Oct 8, 2026
b4d8b2e
feat(teams): submit an event from the public community page
harshtandiya Oct 8, 2026
9d9ee1e
revert(teams): remove submit event from the public community page
harshtandiya Oct 8, 2026
c88694f
feat(dashboard): vue islands for jinja pages, and lucide icons on pub…
harshtandiya Oct 8, 2026
6e7babc
refactor(dashboard): give EmptyState an icon prop
harshtandiya Oct 8, 2026
50ee00d
feat(dashboard): community calendar with pending submissions
harshtandiya Oct 8, 2026
cffb310
feat(teams): external events in community event requests
harshtandiya Oct 8, 2026
10b231f
feat(dashboard): welcome alert and quieter pending approvals on commu…
harshtandiya Oct 8, 2026
da2691a
feat(teams): add and submit events to a community calendar
harshtandiya Oct 8, 2026
aa89bcb
fix(islands): read the vite manifest with get_file_json
harshtandiya Oct 9, 2026
26e3f95
fix(dashboard): render the event card overlay as a native button
harshtandiya Oct 9, 2026
1eeab29
feat(dashboard): put Add Event beside the Upcoming/Past toggle
harshtandiya Oct 9, 2026
0fe29fc
feat(teams): pending approvals as a compact list, newest submission f…
harshtandiya Oct 9, 2026
9d013aa
fix(teams): bound external event fields to what the request stores
harshtandiya Oct 9, 2026
7cd5d54
fix(dashboard): hold the community calendar up under long and large data
harshtandiya Oct 9, 2026
98019ea
fix(dashboard): full-width Add Event menu on mobile
harshtandiya Oct 9, 2026
626a6ae
fix(dashboard): large rows in the mobile Add Event menu
harshtandiya Oct 9, 2026
c980cd8
refactor(dashboard): serve the community calendar at /calendar
harshtandiya Oct 9, 2026
17572cf
feat(teams): confirm community submissions to the submitter
harshtandiya Oct 9, 2026
b94cf2d
feat(teams): route replies on community emails to the other side
harshtandiya Oct 9, 2026
7069a5e
fix(teams): never email Administrator or Guest from community emails
harshtandiya Oct 9, 2026
ce99703
feat(dashboard): ask for a reason when rejecting, and review from the…
harshtandiya Oct 9, 2026
e4c79e4
fix(dashboard): generic placeholder for the rejection reason
harshtandiya Oct 9, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jspm_packages/
.aider*

buzz/public/dashboard/
buzz/public/islands/
buzz/public/node_modules
buzz/www/dashboard.html

Expand Down
20 changes: 18 additions & 2 deletions buzz/api/communities/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import frappe

from buzz.api.communities.queries import find_upcoming_event
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
from buzz.api.communities.schemas import CommunityQueue, EventOption, EventRequests, ExternalEvent
from buzz.api.communities.submissions import EventSubmissions, submit_external_request, submittable_events


@frappe.whitelist()
Expand Down Expand Up @@ -58,3 +59,18 @@ def remove_event(request: str) -> None:
@frappe.whitelist(methods=["POST"])
def add_event(community: str, event: str) -> None:
CommunityReview(community).add(event)


@frappe.whitelist()
def find_event(url: str) -> EventOption:
return find_upcoming_event(url)


@frappe.whitelist(methods=["POST"])
def add_external_event(community: str, event: ExternalEvent) -> None:
CommunityReview(community).add_external_event(event)


@frappe.whitelist(methods=["POST"])
def submit_external_event(community: str, event: ExternalEvent) -> None:
submit_external_request(community, event)
6 changes: 5 additions & 1 deletion buzz/api/communities/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from frappe import _lt

from buzz.api.exceptions import Conflict, NotPermitted
from buzz.api.exceptions import Conflict, NotPermitted, ResourceNotFound


class CannotSubmitEvent(NotPermitted):
Expand All @@ -21,3 +21,7 @@ class RequestNotRejected(Conflict):

class RequestNotApproved(Conflict):
message = _lt("Only an approved event can be removed from the community.")


class EventNotFound(ResourceNotFound):
message = _lt("No upcoming published event lives at this link.")
63 changes: 57 additions & 6 deletions buzz/api/communities/notifications.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import frappe
from frappe import _
from frappe.utils import get_url
from frappe.utils import format_datetime, get_url

from buzz.events.doctype.buzz_team_settings.buzz_team_settings import get_team_settings
from buzz.permissions import WRITE_ROLES


Expand All @@ -10,31 +11,80 @@ def community_managers(community: str) -> list[str]:
return frappe.get_all("Buzz Team Membership", filters=filters, pluck="user")


def community_contact(community: str) -> str | None:
"""Where a submitter's reply goes: the community's support email, else its owner."""
owner = {"team": community, "enabled": 1, "team_role": "Owner"}
return get_team_settings(community).support_email or frappe.db.get_value(
"Buzz Team Membership", owner, "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:
def submitting_team(request) -> str:
# An external event has no Buzz team; its host stands in.
return team_name(request.event_team) if request.event_team else request.host


def external_event(request) -> dict | None:
"""What the email shows for an event hosted elsewhere; a Buzz event gets the base header."""
if not request.is_external_event:
return None
return frappe._dict(
title=request.event_title,
when=format_datetime(request.start_datetime, "EEE, d MMM yyyy, h:mm a"),
place=request.event_location,
host=request.host,
url=request.event_url,
)


def send_email(
request, recipients: list[str], subject: str, template: str, reply_to: str | None = None, **args
) -> None:
# Administrator and Guest have no inbox; mail to them only fails.
recipients = [user for user in recipients if user not in frappe.STANDARD_USERS]
if not recipients:
return
frappe.sendmail(
recipients=recipients,
reply_to=None if reply_to in frappe.STANDARD_USERS else reply_to,
subject=subject,
template=template,
raw_html=True,
add_css=False,
args={"event_title": request.event_title, "community_name": team_name(request.community), **args},
args={
"event_title": request.event_title,
"community_name": team_name(request.community),
"event_doc": frappe.get_cached_doc("Buzz Event", request.event) if request.event else None,
"external_event": external_event(request),
**args,
},
reference_doctype=request.doctype,
reference_name=request.name,
)


def notify_submitted(request) -> None:
submitting_team = team_name(request.event_team)
"""Tells the submitter it arrived, and the community's managers it needs a review."""
if request.submitted_by:
send_email(
request,
[request.submitted_by],
_("{0} was sent to {1}").format(request.event_title, team_name(request.community)),
"community_event_received",
reply_to=community_contact(request.community),
)
send_email(
request,
community_managers(request.community),
_("{0} submitted {1}").format(submitting_team, request.event_title),
_("{0} submitted {1}").format(submitting_team(request), request.event_title),
"community_event_submitted",
team_name=submitting_team,
reply_to=request.submitted_by,
team_name=submitting_team(request),
review_url=get_url(f"/b/manage/communities/{request.community}/calendar"),
)


Expand All @@ -49,6 +99,7 @@ def notify_reviewed(request) -> None:
[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",
reply_to=community_contact(request.community),
community_url=get_url(f"/{community_route}"),
review_note=request.review_note,
)
68 changes: 60 additions & 8 deletions buzz/api/communities/queries.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,55 @@
from datetime import datetime, timedelta
from urllib.parse import urlparse

import frappe
from frappe import _
from frappe.query_builder.functions import Coalesce
from frappe.utils import get_datetime

from buzz.api.communities.exceptions import EventNotFound
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")
"""Requests matching `conditions`, each with its event and both teams spelled out.

An external event has no Buzz Event or team: its row comes from the request's own fields.
"""
request, event, user, venue = (
frappe.qb.DocType(name) for name in ("Community Event Request", "Buzz Event", "User", "Event Venue")
)
event_team, community = frappe.qb.DocType("Buzz Team").as_("event_team"), frappe.qb.DocType("Buzz Team")
query = (
frappe.qb.from_(request)
.join(event)
.left_join(event)
.on(event.name == request.event)
.join(event_team)
.left_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)
.left_join(venue)
.on(venue.name == event.venue)
.select(
request.name,
request.creation,
request.event,
event.title.as_("event_title"),
request.event_title,
request.is_external_event,
request.event_url,
request.host,
request.event_location,
request.start_datetime,
event.route.as_("event_route"),
event.start_date,
event.start_time,
event.medium,
event.banner_image,
venue.venue_name,
request.event_team,
event_team.team_name.as_("event_team_name"),
event_team.logo.as_("event_team_logo"),
Expand All @@ -38,11 +60,32 @@ def request_rows(**conditions) -> list[CommunityRequest]:
user.full_name.as_("submitter_name"),
request.review_note,
)
.orderby(event.start_date)
.orderby(Coalesce(event.start_date, request.start_datetime))
)
for field, value in conditions.items():
query = query.where(request[field] == value)
return [CommunityRequest(**row) for row in query.run(as_dict=True)]
return [CommunityRequest(**request_row(row)) for row in query.run(as_dict=True)]


def request_row(row) -> dict:
"""Fills an external event's date, time, place and host from the request itself."""
medium, venue = row.pop("medium"), row.pop("venue_name")
starts_at, location, host = row.pop("start_datetime"), row.pop("event_location"), row.pop("host")
row.place = _("Online") if medium == "Online" else venue
if row.is_external_event:
starts_at = get_datetime(starts_at)
row.update(
start_date=starts_at.date(),
start_time=time_of_day(starts_at),
place=location,
event_team_name=host,
)
return row


def time_of_day(moment: datetime) -> timedelta:
"""A datetime's time as the timedelta a Time field reads back as."""
return moment - moment.replace(hour=0, minute=0, second=0, microsecond=0)


def upcoming_event_options(filters: dict) -> list[EventOption]:
Expand All @@ -60,3 +103,12 @@ def upcoming_event_options(filters: dict) -> list[EventOption]:
)
for event in exclude_ended_events(events)
]


def find_upcoming_event(url: str) -> EventOption:
"""The upcoming published event at a pasted link, or just its route."""
route = urlparse(url).path.rstrip("/").rpartition("/")[2]
events = upcoming_event_options({"route": route}) if route else []
if not events:
EventNotFound.throw()
return events[0]
12 changes: 10 additions & 2 deletions buzz/api/communities/reviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
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.api.communities.schemas import CommunityQueue, ExternalEvent
from buzz.api.communities.submissions import external_request
from buzz.permissions import has_team_access

REQUEST = "Community Event Request"
Expand All @@ -24,7 +25,11 @@ def from_request(cls, request: str) -> tuple["CommunityReview", "frappe.model.do

def queue(self) -> CommunityQueue:
return CommunityQueue(
pending=request_rows(community=self.community, status="Pending"),
pending=sorted(
request_rows(community=self.community, status="Pending"),
key=lambda row: row.creation,
reverse=True,
),
approved=request_rows(community=self.community, status="Approved"),
)

Expand All @@ -47,6 +52,9 @@ def add(self, event: str) -> None:
request.submitted_by = request.submitted_by or frappe.session.user
self.set_status(request, "Approved")

def add_external_event(self, event: ExternalEvent) -> None:
self.set_status(external_request(self.community, event), "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)
39 changes: 34 additions & 5 deletions buzz/api/communities/schemas.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from datetime import date
from datetime import date, datetime, timedelta
from typing import Annotated

from buzz.api.schemas import APIResponse
from pydantic import Field

from buzz.api.schemas import APIRequest, APIResponse


class CommunityOption(APIResponse):
Expand All @@ -18,12 +21,21 @@ class EventOption(APIResponse):

class CommunityRequest(APIResponse):
name: str
event: str
creation: datetime
# Unset for an external event, which lives on another platform at `event_url`.
event: str | None
is_external_event: bool
event_url: str | None
event_title: str
event_route: str | None
start_date: date
event_team: str
event_team_name: str
start_time: timedelta | None
# The venue, "Online", or for an external event its location.
place: str | None
banner_image: str | None
event_team: str | None
# The event's team, or for an external event its host.
event_team_name: str | None
event_team_logo: str | None
community: str
community_name: str
Expand All @@ -42,3 +54,20 @@ class EventRequests(APIResponse):
class CommunityQueue(APIResponse):
pending: list[CommunityRequest]
approved: list[CommunityRequest]


# Data columns hold 140 characters; a shared link with tracking parameters runs longer.
Text140 = Annotated[str, Field(max_length=140)]
Url = Annotated[str, Field(max_length=500)]


class ExternalEvent(APIRequest):
"""An event hosted on another platform, such as Luma or KonfHub."""

event_title: Text140
host: Text140
event_location: Text140
google_place_id: str | None = None
start_datetime: datetime
end_datetime: datetime
event_url: Url
Loading
Loading