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 %} + +
+
+

Achievements

+
+
+
+
+ {% for ach in achievements %} +
+
+

{{ ach.points }}

+
+
+

{{ 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);