From 7091a09983758b9fc08e6cc00668af381e8a368b Mon Sep 17 00:00:00 2001
From: Jeremy Childers <30885417+jlchilders11@users.noreply.github.com>
Date: Wed, 18 Mar 2026 12:26:03 -0400
Subject: [PATCH 1/3] Story: Add achievement card (#2143)
---
core/views.py | 11 +++
static/css/v3/achivement-card.css | 88 +++++++++++++++++++
static/css/v3/card.css | 13 ++-
static/css/v3/components.css | 1 +
.../v3/examples/_v3_example_section.html | 10 +++
templates/v3/includes/_achievement_card.html | 54 ++++++++++++
6 files changed, 173 insertions(+), 4 deletions(-)
create mode 100644 static/css/v3/achivement-card.css
create mode 100644 templates/v3/includes/_achievement_card.html
diff --git a/core/views.py b/core/views.py
index 9bea576e8..8b539f27a 100644
--- a/core/views.py
+++ b/core/views.py
@@ -1319,6 +1319,17 @@ def get_context_data(self, **kwargs):
],
}
+ context["achievements_data"] = {
+ "achievements": [
+ {
+ "title": "Lorem Ipsum",
+ "points": 22,
+ "description": "A longer description giving a summary of the achievement.",
+ }
+ for _ in range(4)
+ ]
+ }
+
context["banner_data"] = {
"icon_name": "alert",
"banner_message": "This is an older version of Boost and was released in 2017. The current version is 1.90.0.",
diff --git a/static/css/v3/achivement-card.css b/static/css/v3/achivement-card.css
new file mode 100644
index 000000000..e006e23a0
--- /dev/null
+++ b/static/css/v3/achivement-card.css
@@ -0,0 +1,88 @@
+.achievement-card__bold-text {
+ color: var(--color-text-primary);
+
+ /* Sans/Desktop/Medium/Base */
+ font-family: var(--font-sans);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-default);
+ /* 19.2px */
+ letter-spacing: var(--letter-spacing-tight);
+}
+
+.achievement-card__container {
+ display: flex;
+ align-items: flex-start;
+ align-content: flex-start;
+ gap: 16px var(--space-large);
+ align-self: stretch;
+ flex-wrap: wrap;
+ width: 100%;
+}
+
+.achievement-card__container p {
+ padding-bottom: 0;
+ padding-top: 0;
+}
+
+.achievement-card__achievement {
+ display: flex;
+ flex: 1 1 34%;
+ align-items: flex-start;
+ gap: var(--space-default);
+}
+
+.achievement-card__achivement-example {
+ opacity: 0.7;
+}
+
+@media screen and (max-width: 768px) {
+ .achievement-card__achievement {
+ flex: 1 1 51%;
+ }
+}
+
+.achievement-card__points-badge {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ aspect-ratio: 1/1;
+ border-radius: var(--space-s);
+ background: var(--color-icon-brand-accent);
+}
+
+.achievement-card__points-badge-content {
+ display: flex;
+ width: 16px;
+ height: 16px;
+ flex-direction: column;
+ justify-content: center;
+ aspect-ratio: 1/1;
+ color: var(--color-text-on-accent);
+ text-align: center;
+ font-family: var(--font-sans);
+ font-size: 10px;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--letter-spacing-tight);
+ /* 10px */
+ letter-spacing: -0.1px;
+}
+
+.achievement-card__description {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ gap: var(--space-s);
+}
+
+.achievement-card__ach-text {
+ /* Sans/Desktop/Regular/XS/Default */
+ font-family: var(--font-sans);
+ font-size: var(--font-size-xs);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-default);
+ /* 14.4px */
+ letter-spacing: -0.12px;
+}
diff --git a/static/css/v3/card.css b/static/css/v3/card.css
index 9a00e752b..43a8e09f1 100644
--- a/static/css/v3/card.css
+++ b/static/css/v3/card.css
@@ -24,6 +24,10 @@
max-width: 458px;
}
+.wide-card {
+ max-width: 696px;
+}
+
.card .btn-row {
width: 100%;
}
@@ -33,10 +37,11 @@
}
.card__column {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: var(--space-large);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-large);
+ width: 100%;
}
.card__header {
diff --git a/static/css/v3/components.css b/static/css/v3/components.css
index 5597042f0..da3dbfcc7 100644
--- a/static/css/v3/components.css
+++ b/static/css/v3/components.css
@@ -32,3 +32,4 @@
@import "./animations.css";
@import "./account-connections.css";
@import "./dialog.css";
+@import "./achivement-card.css";
diff --git a/templates/v3/examples/_v3_example_section.html b/templates/v3/examples/_v3_example_section.html
index 5947d0cc0..a2a0a57e4 100644
--- a/templates/v3/examples/_v3_example_section.html
+++ b/templates/v3/examples/_v3_example_section.html
@@ -361,6 +361,16 @@
Category tags
Event cards
{% include "v3/includes/_event_cards.html" %}
+
+
Achievement cards
+
+
Empty Card
+ {% include "v3/includes/_achievement_card.html" %}
+ Card with Achievements
+ {% include "v3/includes/_achievement_card.html" with achievements=achievements_data.achievements primary_button_url="https://www.example.com" %}
+
+
+
Content event list – clickable cards (link wraps each card)
diff --git a/templates/v3/includes/_achievement_card.html b/templates/v3/includes/_achievement_card.html
new file mode 100644
index 000000000..f75010ee9
--- /dev/null
+++ b/templates/v3/includes/_achievement_card.html
@@ -0,0 +1,54 @@
+{% comment %}
+ This card lists the achievements for a user. If not provided a list of achievements, instead give a demonstration of achievements and link to the about page
+ Inputs:
+ Achievements - Optional: A list of achievements, each of which has points, a title, and a description
+{% endcomment %}
+
+
+
+
+
+
+ {% for ach in achievements %}
+
+
+
+
{{ ach.title }}
+
{{ ach.description }}
+
+
+ {% empty %}
+
+
+ 19
+
+
+
Patch Wizard
+
Submitted patch that was reviewed and merged
+
+
+
+
+ 7
+
+
+
Consensus Builder
+
Guided technical discussion toward agreement
+
+
+ {% endfor %}
+
+ {% if not achievements %}
+
Showcase your contributions and milestones as you participate in the community.
+ {% endif %}
+
+ {% if not achievements %}
+
+ {% include 'v3/includes/_button.html' with style=primary_style url=primary_button_url label='Learn how achievements work' only %}
+
+ {% endif %}
+
From ff801369431254c5fb50f073b030d2832b8aff90 Mon Sep 17 00:00:00 2001
From: Greg Kaleka
Date: Wed, 18 Mar 2026 13:02:26 -0400
Subject: [PATCH 2/3] Add Monday.com CRM sync for contacts and leads (#2194)
---
config/settings.py | 5 +
marketing/admin.py | 9 +
marketing/management/__init__.py | 0
marketing/management/commands/__init__.py | 0
.../management/commands/sync_monday_crm.py | 81 ++++
...ptured_email_constraint_and_gh_username.py | 34 ++
marketing/models.py | 9 +
marketing/monday.py | 459 ++++++++++++++++++
marketing/tasks.py | 8 +
9 files changed, 605 insertions(+)
create mode 100644 marketing/management/__init__.py
create mode 100644 marketing/management/commands/__init__.py
create mode 100644 marketing/management/commands/sync_monday_crm.py
create mode 100644 marketing/migrations/0004_captured_email_constraint_and_gh_username.py
create mode 100644 marketing/monday.py
create mode 100644 marketing/tasks.py
diff --git a/config/settings.py b/config/settings.py
index 9c5ca3df2..359433b95 100755
--- a/config/settings.py
+++ b/config/settings.py
@@ -401,6 +401,11 @@
JDOODLE_API_CLIENT_ID = env("JDOODLE_API_CLIENT_ID", "")
JDOODLE_API_CLIENT_SECRET = env("JDOODLE_API_CLIENT_SECRET", "")
+# Monday.com CRM integration
+MONDAY_API_TOKEN = env("MONDAY_API_TOKEN", default="")
+MONDAY_CONTACTS_BOARD_ID = env("MONDAY_CONTACTS_BOARD_ID", default="")
+MONDAY_LEADS_BOARD_ID = env("MONDAY_LEADS_BOARD_ID", default="")
+
# Django Allauth settings
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
diff --git a/marketing/admin.py b/marketing/admin.py
index a9cc70028..a62d421e5 100644
--- a/marketing/admin.py
+++ b/marketing/admin.py
@@ -19,6 +19,14 @@ class CapturedEmailResource(resources.ModelResource):
address_country = fields.Field(
column_name="Address (Country)", attribute="address_country"
)
+ github_username = fields.Field(
+ column_name="GitHub Username", attribute="github_username"
+ )
+
+ def skip_row(self, instance, original, row, import_validation_errors=None):
+ if not instance.email:
+ return True
+ return super().skip_row(instance, original, row, import_validation_errors)
class Meta:
model = CapturedEmail
@@ -36,6 +44,7 @@ class Meta:
"address_city",
"address_state",
"address_country",
+ "github_username",
)
diff --git a/marketing/management/__init__.py b/marketing/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/marketing/management/commands/__init__.py b/marketing/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/marketing/management/commands/sync_monday_crm.py b/marketing/management/commands/sync_monday_crm.py
new file mode 100644
index 000000000..b6fb9e254
--- /dev/null
+++ b/marketing/management/commands/sync_monday_crm.py
@@ -0,0 +1,81 @@
+import logging
+
+import djclick as click
+from django.contrib.auth import get_user_model
+
+from marketing.models import CapturedEmail
+from marketing.monday import MondayClient
+
+User = get_user_model()
+logger = logging.getLogger(__name__)
+
+
+@click.command()
+@click.option(
+ "--dry-run",
+ is_flag=True,
+ default=False,
+ help="Print what would be synced without making API calls.",
+)
+@click.option(
+ "--board",
+ type=click.Choice(["contacts", "leads", "all"]),
+ default="all",
+ show_default=True,
+ help="Which Monday.com board to sync.",
+)
+def command(dry_run, board):
+ """Sync Users and CapturedEmails to Monday.com CRM boards.
+
+ contacts: active + claimed Users -> Contacts board
+ leads: non-opted-out emails -> Leads board
+ all: both boards (default)
+ """
+ if dry_run:
+ click.secho("DRY RUN — no data will be sent to Monday.com", fg="yellow")
+
+ client = None if dry_run else MondayClient()
+
+ if board in ("contacts", "all"):
+ _sync_contacts(client, dry_run)
+
+ if board in ("leads", "all"):
+ _sync_leads(client, dry_run)
+
+
+def _sync_contacts(client, dry_run):
+ users = User.objects.filter(is_active=True, claimed=True).order_by("pk")
+ total = users.count()
+ click.secho(f"Syncing {total} contacts to Monday.com Contacts board...", fg="green")
+
+ if dry_run:
+ for user in users.iterator():
+ click.echo(f" [DRY RUN] contact: {user.email}")
+ return
+
+ created, updated = client.bulk_upsert_contacts(users)
+ click.secho(
+ f"Contacts done: {created} created, {updated} updated.",
+ fg="green",
+ )
+
+
+def _sync_leads(client, dry_run):
+ leads = (
+ CapturedEmail.objects.filter(opted_out=False)
+ .select_related("page")
+ .order_by("pk")
+ )
+ total = leads.count()
+ click.secho(f"Syncing {total} leads to Monday.com Leads board...", fg="green")
+
+ if dry_run:
+ for lead in leads.iterator():
+ click.echo(f" [DRY RUN] lead: {lead.email}")
+ return
+
+ created, updated = client.bulk_upsert_leads(leads)
+ click.secho(
+ f"Leads done: {created} created, {updated} updated.",
+ fg="green",
+ )
diff --git a/marketing/migrations/0004_captured_email_constraint_and_gh_username.py b/marketing/migrations/0004_captured_email_constraint_and_gh_username.py
new file mode 100644
index 000000000..2f1957826
--- /dev/null
+++ b/marketing/migrations/0004_captured_email_constraint_and_gh_username.py
@@ -0,0 +1,34 @@
+# Generated by Django 6.0.2 on 2026-03-06 01:57
+
+from django.db import migrations, models
+
+
+def delete_blank_emails(apps, schema_editor):
+ CapturedEmail = apps.get_model("marketing", "CapturedEmail")
+ count, _ = CapturedEmail.objects.filter(email="").delete()
+ if count:
+ print(f"\n Deleted {count} CapturedEmail record(s) with blank email.")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("marketing", "0003_capturedemail_address_city_and_more"),
+ ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"),
+ ]
+
+ operations = [
+ migrations.RunPython(delete_blank_emails, migrations.RunPython.noop),
+ migrations.AddField(
+ model_name="capturedemail",
+ name="github_username",
+ field=models.CharField(blank=True, default=""),
+ ),
+ migrations.AddConstraint(
+ model_name="capturedemail",
+ constraint=models.CheckConstraint(
+ condition=models.Q(("email", ""), _negated=True),
+ name="captured_email_requires_email",
+ ),
+ ),
+ ]
diff --git a/marketing/models.py b/marketing/models.py
index 8f3d6b82a..44bb3cf59 100644
--- a/marketing/models.py
+++ b/marketing/models.py
@@ -22,6 +22,7 @@ class CapturedEmail(models.Model):
address_city = models.CharField(blank=True, default="")
address_state = models.CharField(blank=True, default="")
address_country = models.CharField(blank=True, default="")
+ github_username = models.CharField(blank=True, default="")
opted_out = models.BooleanField(default=False)
referrer = models.CharField(blank=True, default="")
@@ -35,6 +36,14 @@ class CapturedEmail(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True)
+ class Meta:
+ constraints = [
+ models.CheckConstraint(
+ condition=~models.Q(email=""),
+ name="captured_email_requires_email",
+ ),
+ ]
+
def __str__(self):
return self.email
diff --git a/marketing/monday.py b/marketing/monday.py
new file mode 100644
index 000000000..29a3c2ebf
--- /dev/null
+++ b/marketing/monday.py
@@ -0,0 +1,459 @@
+import json
+import logging
+import time
+
+import requests
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+MONDAY_API_URL = "https://api.monday.com/v2"
+
+# Monday.com column ID mappings — these are structural constants tied to the
+# board schemas, not secrets. Update these if the board columns change.
+CONTACTS_COLUMNS = {
+ "email": "contact_email",
+ "github_username": "text_mm14zw93",
+ "date_joined": "date_mm1495ng",
+ "last_login": "date_mm147ks8",
+}
+
+LEADS_COLUMNS = {
+ "email": "lead_email",
+ "company": "lead_company",
+ "title": "text",
+ "first_name": "text_mm14gktd",
+ "last_name": "text_mm14cqj4",
+ "github_username": "text_mm1dvdrt",
+ "city": "text_mm14v5h5",
+ "state": "text_mm14p6vv",
+ "country": "text_mm14jjtj",
+ "referrer": "text_mm14ebdc",
+ "page": "text_mm1e9bx7",
+ "captured_at": "date_mm14hcxb",
+}
+
+
+class MondayAPIError(Exception):
+ """Raised when the Monday.com API returns an error response."""
+
+
+class MondayRateLimitError(MondayAPIError):
+ """Raised when the Monday.com API returns a 429 rate limit response."""
+
+ def __init__(self, retry_after=30):
+ self.retry_after = retry_after
+ super().__init__(f"Rate limited, retry after {retry_after}s")
+
+
+class MondayClient:
+ """
+ Minimal Monday.com GraphQL client for one-way CRM push.
+
+ Required settings:
+ MONDAY_API_TOKEN - personal API token
+ MONDAY_CONTACTS_BOARD_ID - board ID for the Contacts board
+ MONDAY_LEADS_BOARD_ID - board ID for the Leads board
+ """
+
+ def __init__(self, token=None):
+ self.token = token or settings.MONDAY_API_TOKEN
+ if not self.token:
+ raise ValueError(
+ "No Monday.com API token provided or found in MONDAY_API_TOKEN setting."
+ )
+ for attr in ("MONDAY_CONTACTS_BOARD_ID", "MONDAY_LEADS_BOARD_ID"):
+ if not getattr(settings, attr, None):
+ raise ValueError(f"{attr} is not configured.")
+ self.session = requests.Session()
+ self.session.headers.update(
+ {
+ "Authorization": self.token,
+ "Content-Type": "application/json",
+ }
+ )
+
+ # ------------------------------------------------------------------
+ # Low-level GraphQL
+ # ------------------------------------------------------------------
+
+ def _query(self, query, variables=None):
+ """Execute a GraphQL query/mutation. Raises on HTTP or API errors."""
+ payload = {"query": query}
+ if variables:
+ payload["variables"] = variables
+
+ response = self.session.post(MONDAY_API_URL, json=payload)
+ if response.status_code == 429:
+ retry_after = int(response.headers.get("Retry-After", 30))
+ raise MondayRateLimitError(retry_after)
+ response.raise_for_status()
+
+ data = response.json()
+ if "errors" in data:
+ raise MondayAPIError(f"Monday.com API error: {data['errors']}")
+ return data["data"]
+
+ def _query_with_retry(self, query, variables=None, max_retries=3):
+ """Execute a GraphQL query with exponential backoff retry.
+
+ Non-transient errors (MondayAPIError) are raised immediately.
+ Only transient errors (network, HTTP 5xx) are retried.
+ """
+ for attempt in range(1, max_retries + 1):
+ try:
+ return self._query(query, variables)
+ except MondayRateLimitError as e:
+ if attempt == max_retries:
+ raise
+ logger.warning(
+ "Monday.com rate limited, waiting %ds (attempt %d/%d)",
+ e.retry_after,
+ attempt,
+ max_retries,
+ )
+ time.sleep(e.retry_after)
+ except MondayAPIError:
+ raise
+ except requests.RequestException:
+ if attempt == max_retries:
+ raise
+ wait = 2**attempt
+ logger.warning(
+ "Monday.com API retry attempt=%d, waiting %ds", attempt, wait
+ )
+ time.sleep(wait)
+
+ # ------------------------------------------------------------------
+ # Search
+ # ------------------------------------------------------------------
+
+ def find_item_by_email(self, board_id, email_column_id, email):
+ """
+ Search a board for an item matching the given email.
+ Returns the item ID string if found, else None.
+ """
+ query = """
+ query ($board_id: ID!, $column_id: String!, $value: String!) {
+ items_page_by_column_values(
+ limit: 1
+ board_id: $board_id
+ columns: [{column_id: $column_id, column_values: [$value]}]
+ ) {
+ items { id }
+ }
+ }
+ """
+ variables = {
+ "board_id": str(board_id),
+ "column_id": email_column_id,
+ "value": email,
+ }
+ data = self._query_with_retry(query, variables)
+ items = data.get("items_page_by_column_values", {}).get("items", [])
+ return items[0]["id"] if items else None
+
+ def get_all_emails(self, board_id, email_column_id):
+ """Fetch all items from a board, returning {email: item_id}."""
+ lookup = {}
+ cursor = None
+ while True:
+ if cursor:
+ query = """
+ query ($cursor: String!, $col_ids: [String!]) {
+ next_items_page(limit: 500, cursor: $cursor) {
+ cursor
+ items { id column_values(ids: $col_ids) { text } }
+ }
+ }
+ """
+ variables = {"cursor": cursor, "col_ids": [email_column_id]}
+ data = self._query_with_retry(query, variables)
+ page = data["next_items_page"]
+ else:
+ query = """
+ query ($board_id: ID!, $col_ids: [String!]) {
+ boards(ids: [$board_id]) {
+ items_page(limit: 500) {
+ cursor
+ items { id column_values(ids: $col_ids) { text } }
+ }
+ }
+ }
+ """
+ variables = {
+ "board_id": str(board_id),
+ "col_ids": [email_column_id],
+ }
+ data = self._query_with_retry(query, variables)
+ page = data["boards"][0]["items_page"]
+
+ for item in page["items"]:
+ email = (item["column_values"][0]["text"] or "").strip()
+ if email:
+ lookup[email] = item["id"]
+
+ cursor = page["cursor"]
+ if not cursor:
+ break
+ return lookup
+
+ # ------------------------------------------------------------------
+ # Mutations
+ # ------------------------------------------------------------------
+
+ BATCH_SIZE = 25
+
+ def create_item(self, board_id, item_name, column_values):
+ """Create a new item on a board. Returns the new item ID."""
+ mutation = """
+ mutation ($board_id: ID!, $item_name: String!, $column_values: JSON!) {
+ create_item(
+ board_id: $board_id
+ item_name: $item_name
+ column_values: $column_values
+ ) { id }
+ }
+ """
+ variables = {
+ "board_id": str(board_id),
+ "item_name": item_name,
+ "column_values": json.dumps(column_values),
+ }
+ data = self._query_with_retry(mutation, variables)
+ return data["create_item"]["id"]
+
+ def create_items_batch(self, board_id, items):
+ """Create multiple items in one API call using aliased mutations.
+
+ items: list of (item_name, column_values) tuples.
+ Returns list of created item IDs.
+ """
+ if not items:
+ return []
+ parts = []
+ variables = {"board_id": str(board_id)}
+ for i, (name, col_vals) in enumerate(items):
+ parts.append(
+ f"i{i}: create_item(board_id: $board_id, "
+ f"item_name: $name_{i}, column_values: $cols_{i}) {{ id }}"
+ )
+ variables[f"name_{i}"] = name
+ variables[f"cols_{i}"] = json.dumps(col_vals)
+
+ type_decls = ["$board_id: ID!"]
+ for i in range(len(items)):
+ type_decls.append(f"$name_{i}: String!")
+ type_decls.append(f"$cols_{i}: JSON!")
+
+ mutation = "mutation (%s) { %s }" % (
+ ", ".join(type_decls),
+ " ".join(parts),
+ )
+ data = self._query_with_retry(mutation, variables)
+ return [data[f"i{i}"]["id"] for i in range(len(items))]
+
+ def update_items_batch(self, board_id, items):
+ """Update multiple items in one API call using aliased mutations.
+
+ items: list of (item_id, column_values) tuples.
+ Returns list of updated item IDs.
+ """
+ if not items:
+ return []
+ parts = []
+ variables = {"board_id": str(board_id)}
+ for i, (item_id, col_vals) in enumerate(items):
+ parts.append(
+ f"i{i}: change_multiple_column_values(board_id: $board_id, "
+ f"item_id: $id_{i}, column_values: $cols_{i}) {{ id }}"
+ )
+ variables[f"id_{i}"] = str(item_id)
+ variables[f"cols_{i}"] = json.dumps(col_vals)
+
+ type_decls = ["$board_id: ID!"]
+ for i in range(len(items)):
+ type_decls.append(f"$id_{i}: ID!")
+ type_decls.append(f"$cols_{i}: JSON!")
+
+ mutation = "mutation (%s) { %s }" % (
+ ", ".join(type_decls),
+ " ".join(parts),
+ )
+ data = self._query_with_retry(mutation, variables)
+ return [data[f"i{i}"]["id"] for i in range(len(items))]
+
+ def update_item(self, board_id, item_id, column_values):
+ """Update column values on an existing item. Returns item ID."""
+ mutation = """
+ mutation ($board_id: ID!, $item_id: ID!, $column_values: JSON!) {
+ change_multiple_column_values(
+ board_id: $board_id
+ item_id: $item_id
+ column_values: $column_values
+ ) { id }
+ }
+ """
+ variables = {
+ "board_id": str(board_id),
+ "item_id": str(item_id),
+ "column_values": json.dumps(column_values),
+ }
+ data = self._query_with_retry(mutation, variables)
+ return data["change_multiple_column_values"]["id"]
+
+ # ------------------------------------------------------------------
+ # Upsert (composed from search + create/update)
+ # ------------------------------------------------------------------
+
+ def _format_date(self, dt):
+ """Format a datetime for Monday.com date columns (YYYY-MM-DD)."""
+ if dt is None:
+ return None
+ return dt.strftime("%Y-%m-%d")
+
+ def _contact_row(self, user):
+ """Build (item_name, column_values) for a User."""
+ email_col = CONTACTS_COLUMNS["email"]
+ name = (user.display_name or "").strip() or user.email.split("@")[0]
+ column_values = {
+ email_col: {"email": user.email, "text": user.email},
+ }
+ if user.github_username:
+ column_values[CONTACTS_COLUMNS["github_username"]] = user.github_username
+ if user.date_joined:
+ column_values[CONTACTS_COLUMNS["date_joined"]] = self._format_date(
+ user.date_joined
+ )
+ if user.last_login:
+ column_values[CONTACTS_COLUMNS["last_login"]] = self._format_date(
+ user.last_login
+ )
+ return name, column_values
+
+ def _lead_row(self, captured_email):
+ """Build (item_name, column_values) for a CapturedEmail."""
+ email_col = LEADS_COLUMNS["email"]
+ column_values = {
+ email_col: {"email": captured_email.email, "text": captured_email.email},
+ }
+ for field, key in [
+ ("company", "company"),
+ ("title", "title"),
+ ("first_name", "first_name"),
+ ("last_name", "last_name"),
+ ("github_username", "github_username"),
+ ("address_city", "city"),
+ ("address_state", "state"),
+ ("address_country", "country"),
+ ("referrer", "referrer"),
+ ]:
+ value = getattr(captured_email, field, "")
+ if value:
+ column_values[LEADS_COLUMNS[key]] = value
+ if captured_email.page:
+ column_values[LEADS_COLUMNS["page"]] = captured_email.page.title
+ if captured_email.created_at:
+ column_values[LEADS_COLUMNS["captured_at"]] = self._format_date(
+ captured_email.created_at
+ )
+ return captured_email.email, column_values
+
+ def upsert_contact(self, user):
+ """
+ Upsert a User instance into the Contacts board.
+ Returns (item_id, "created" | "updated").
+ """
+ board_id = settings.MONDAY_CONTACTS_BOARD_ID
+ email_col = CONTACTS_COLUMNS["email"]
+ name, column_values = self._contact_row(user)
+
+ item_id = self.find_item_by_email(board_id, email_col, user.email)
+ if item_id:
+ self.update_item(board_id, item_id, column_values)
+ return item_id, "updated"
+ else:
+ item_id = self.create_item(board_id, name, column_values)
+ return item_id, "created"
+
+ def upsert_lead(self, captured_email):
+ """
+ Upsert a CapturedEmail instance into the Leads board.
+ Returns (item_id, "created" | "updated").
+ """
+ board_id = settings.MONDAY_LEADS_BOARD_ID
+ email_col = LEADS_COLUMNS["email"]
+ name, column_values = self._lead_row(captured_email)
+
+ item_id = self.find_item_by_email(board_id, email_col, captured_email.email)
+ if item_id:
+ self.update_item(board_id, item_id, column_values)
+ return item_id, "updated"
+ else:
+ item_id = self.create_item(board_id, name, column_values)
+ return item_id, "created"
+
+ def _commit_batch(self, board_id, create_buf, update_buf, counters):
+ """Commit create/update buffers when they reach BATCH_SIZE."""
+ if len(create_buf) >= self.BATCH_SIZE:
+ self.create_items_batch(board_id, create_buf)
+ counters[0] += len(create_buf)
+ create_buf.clear()
+ if len(update_buf) >= self.BATCH_SIZE:
+ self.update_items_batch(board_id, update_buf)
+ counters[1] += len(update_buf)
+ update_buf.clear()
+
+ def bulk_upsert_contacts(self, users):
+ """Bulk upsert Users into the Contacts board. Returns (created, updated)."""
+ board_id = settings.MONDAY_CONTACTS_BOARD_ID
+ email_col = CONTACTS_COLUMNS["email"]
+ existing = self.get_all_emails(board_id, email_col)
+
+ create_buf, update_buf = [], []
+ counters = [0, 0] # [created, updated]
+ for user in users.iterator():
+ name, col_vals = self._contact_row(user)
+ item_id = existing.get(user.email)
+ if item_id:
+ update_buf.append((item_id, col_vals))
+ else:
+ create_buf.append((name, col_vals))
+ self._commit_batch(board_id, create_buf, update_buf, counters)
+
+ # Flush remaining
+ if create_buf:
+ self.create_items_batch(board_id, create_buf)
+ counters[0] += len(create_buf)
+ if update_buf:
+ self.update_items_batch(board_id, update_buf)
+ counters[1] += len(update_buf)
+
+ return counters[0], counters[1]
+
+ def bulk_upsert_leads(self, leads):
+ """Bulk upsert CapturedEmails into the Leads board. Returns (created, updated)."""
+ board_id = settings.MONDAY_LEADS_BOARD_ID
+ email_col = LEADS_COLUMNS["email"]
+ existing = self.get_all_emails(board_id, email_col)
+
+ create_buf, update_buf = [], []
+ counters = [0, 0] # [created, updated]
+ for lead in leads.iterator():
+ name, col_vals = self._lead_row(lead)
+ item_id = existing.get(lead.email)
+ if item_id:
+ update_buf.append((item_id, col_vals))
+ else:
+ create_buf.append((name, col_vals))
+ self._commit_batch(board_id, create_buf, update_buf, counters)
+
+ # Flush remaining
+ if create_buf:
+ self.create_items_batch(board_id, create_buf)
+ counters[0] += len(create_buf)
+ if update_buf:
+ self.update_items_batch(board_id, update_buf)
+ counters[1] += len(update_buf)
+
+ return counters[0], counters[1]
diff --git a/marketing/tasks.py b/marketing/tasks.py
new file mode 100644
index 000000000..1b6306c9e
--- /dev/null
+++ b/marketing/tasks.py
@@ -0,0 +1,8 @@
+from django.core.management import call_command
+
+from config.celery import app
+
+
+@app.task(soft_time_limit=900, time_limit=960)
+def sync_monday_crm():
+ call_command("sync_monday_crm")
From 572a47e87204809f8be137bdd88aae138164e56a Mon Sep 17 00:00:00 2001
From: Gabriel Garcia
Date: Wed, 18 Mar 2026 12:41:57 -0500
Subject: [PATCH 3/3] Refactor badge styles to use grid layout for better
centering and update post-card focus styles with improved accessibility
features.
---
static/css/v3/badge.css | 8 +++++---
static/css/v3/post-cards.css | 8 ++++++++
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/static/css/v3/badge.css b/static/css/v3/badge.css
index 92c6e1d42..fed6cfc3f 100644
--- a/static/css/v3/badge.css
+++ b/static/css/v3/badge.css
@@ -4,17 +4,19 @@
*/
.badge-v3 {
- display: flex;
width: 16px;
height: 16px;
- justify-content: center;
- align-items: center;
+ display: grid;
+ place-items: center;
aspect-ratio: 1 / 1;
box-sizing: border-box;
border-radius: var(--s, 4px);
background: var(--Icon-Brand-Accent, #FFA000);
font-size: 10px;
line-height: 1;
+ text-align: center;
+ padding: 0;
+ margin: 0;
color: var(--color-text-on-accent);
pointer-events: none;
}
diff --git a/static/css/v3/post-cards.css b/static/css/v3/post-cards.css
index d7e7fd4d6..dd757557e 100644
--- a/static/css/v3/post-cards.css
+++ b/static/css/v3/post-cards.css
@@ -17,6 +17,14 @@
text-decoration: underline;
}
+.post-cards .content-detail-icon__cta:focus-visible,
+.post-cards .content-card__cta:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--color-stroke-link-accent, #1F3044);
+ border-radius: 4px;
+ display: inline-block;
+}
+
.post-cards {
font-family: var(--font-sans);
color: var(--color-text-primary);