From 777c107c63556a5d76191b4c130dbac09f0c9352 Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Fri, 22 May 2026 20:18:39 +0100 Subject: [PATCH 01/37] feat: UI fixes, ticket waves, ticket image (#52) * email input fixes * promo code ui fix * ticket waves * works, but horrible slop * slightly cleaner * filter most recent * make * pyright --- __init__.py | 3 +- crud.py | 45 +++- models.py | 117 +++++++- services.py | 241 ++++++++++++----- static/image/ticket.jpg | Bin 0 -> 106602 bytes static/js/display.js | 57 +++- static/js/display.vue | 18 +- static/js/index.js | 502 +++++++++++++++++++++++++++++----- static/js/index.vue | 582 ++++++++++++++++++++++++++++++---------- static/js/ticket.js | 29 +- static/js/ticket.vue | 49 ++++ views_api.py | 240 ++++++++++++++--- 12 files changed, 1569 insertions(+), 314 deletions(-) create mode 100644 static/image/ticket.jpg diff --git a/__init__.py b/__init__.py index ef37528..827b8d5 100644 --- a/__init__.py +++ b/__init__.py @@ -6,12 +6,13 @@ from loguru import logger from .crud import db from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import events_api_router, tickets_api_router +from .views_api import events_api_router, qr_api_router, tickets_api_router events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) events_ext.include_router(tickets_api_router) +events_ext.include_router(qr_api_router) events_static_files = [ { diff --git a/crud.py b/crud.py index 3046f0e..930915e 100644 --- a/crud.py +++ b/crud.py @@ -1,9 +1,17 @@ from datetime import datetime, timedelta, timezone +from typing import cast -from lnbits.db import Database +from lnbits.db import Database, Filters, Page from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, Ticket, TicketExtra +from .models import ( + CreateEvent, + Event, + Ticket, + TicketExtra, + TicketFilters, + sync_event_ticket_waves, +) db = Database("ext_events") @@ -51,6 +59,31 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]: ) +async def get_tickets_paginated( + wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None +) -> Page[Ticket]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + wallet_where = [] + values = {} + for idx, wallet_id in enumerate(wallet_ids): + key = f"wallet_id_{idx}" + wallet_where.append(f":{key}") + values[key] = wallet_id + + where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"] + + return await db.fetch_page( + "SELECT * FROM events.ticket", + where=where, + values=values, + filters=filters, + model=Ticket, + table_name="events.ticket", + ) + + async def delete_ticket(payment_hash: str) -> None: await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) @@ -75,31 +108,35 @@ async def purge_unpaid_tickets(event_id: str) -> None: async def create_event(data: CreateEvent) -> Event: event_id = urlsafe_short_hash() event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) + event = cast(Event, sync_event_ticket_waves(event)) await db.insert("events.events", event) return event async def update_event(event: Event) -> Event: + event = cast(Event, sync_event_ticket_waves(event)) await db.update("events.events", event) return event async def get_event(event_id: str) -> Event | None: - return await db.fetchone( + event = await db.fetchone( "SELECT * FROM events.events WHERE id = :id", {"id": event_id}, Event, ) + return cast(Event, sync_event_ticket_waves(event)) if event else None async def get_events(wallet_ids: str | list[str]) -> list[Event]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) - return await db.fetchall( + events = await db.fetchall( f"SELECT * FROM events.events WHERE wallet IN ({q})", model=Event, ) + return [cast(Event, sync_event_ticket_waves(event)) for event in events] async def delete_event(event_id: str) -> None: diff --git a/models.py b/models.py index fa1569f..331ad6d 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,8 @@ -from datetime import datetime +from datetime import date, datetime +from uuid import uuid4 from fastapi import Query +from lnbits.db import FilterModel from pydantic import BaseModel, EmailStr, Field, validator @@ -20,8 +22,23 @@ class PromoCode(BaseModel): return v +class TicketWave(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex[:8]) + title: str = "Primary wave" + opening_date: str + closing_date: str + currency: str = "sat" + use_ticket_image: bool = False + ticket_image_id: str | None = None + allow_fiat: bool = False + fiat_currency: str = "GBP" + amount_tickets: int = Field(default=0, ge=0) + price_per_ticket: float = Field(default=0, ge=0) + + class EventExtra(BaseModel): promo_codes: list[PromoCode] = Field(default_factory=list) + ticket_waves: list[TicketWave] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 email_notifications: bool = False @@ -84,6 +101,8 @@ class PublicEvent(BaseModel): class TicketExtra(BaseModel): applied_promo_code: str | None = None + ticket_wave_id: str | None = None + ticket_wave_title: str | None = None sats_paid: int | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -96,6 +115,7 @@ class TicketExtra(BaseModel): class CreateTicket(BaseModel): name: str email: EmailStr + ticket_wave_id: str | None = None promo_code: str | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -116,6 +136,22 @@ class Ticket(BaseModel): extra: TicketExtra = Field(default_factory=TicketExtra) +class NotificationDeliveryResult(BaseModel): + attempted: bool = False + sent: bool = False + error: str | None = None + + +class TicketResendResult(BaseModel): + ticket: Ticket + email: NotificationDeliveryResult = Field( + default_factory=NotificationDeliveryResult + ) + nostr: NotificationDeliveryResult = Field( + default_factory=NotificationDeliveryResult + ) + + class PublicTicket(BaseModel): event: str name: str @@ -131,3 +167,82 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False + + +class TicketFilters(FilterModel): + __search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012 + __sort_fields__ = [ # noqa: RUF012 + "time", + "event", + "name", + "email", + "registered", + "id", + ] + + event: str | None = None + name: str | None = None + email: str | None = None + registered: bool | None = None + paid: bool | None = None + id: str | None = None + + +def _parse_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() + + +def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]: + ticket_waves = list(getattr(event.extra, "ticket_waves", []) or []) + if ticket_waves: + return ticket_waves + + fallback_opening_date = None + event_time = getattr(event, "time", None) + if event_time: + fallback_opening_date = event_time.date().isoformat() + if not fallback_opening_date: + fallback_opening_date = event.closing_date + + return [ + TicketWave( + id="primary", + title="Primary wave", + opening_date=fallback_opening_date, + closing_date=event.closing_date, + currency=event.currency, + allow_fiat=event.allow_fiat, + fiat_currency=event.fiat_currency, + amount_tickets=getattr(event, "amount_tickets", 0), + price_per_ticket=event.price_per_ticket, + ) + ] + + +def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent: + ticket_waves = ensure_ticket_waves(event) + event.extra.ticket_waves = ticket_waves + + primary_wave = ticket_waves[0] + event.closing_date = max(wave.closing_date for wave in ticket_waves) + event.currency = primary_wave.currency + event.allow_fiat = primary_wave.allow_fiat + event.fiat_currency = primary_wave.fiat_currency + event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves) + event.price_per_ticket = primary_wave.price_per_ticket + + return event + + +def get_active_ticket_waves( + event: Event | PublicEvent, today: date | None = None +) -> list[TicketWave]: + current_day = today or datetime.utcnow().date() + return [ + wave + for wave in ensure_ticket_waves(event) + if _parse_date(wave.opening_date) + <= current_day + <= _parse_date(wave.closing_date) + and wave.amount_tickets > 0 + ] diff --git a/services.py b/services.py index 159bbdc..057026b 100644 --- a/services.py +++ b/services.py @@ -1,15 +1,15 @@ from __future__ import annotations +import smtplib from asyncio.tasks import create_task +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from html import escape from lnbits.core.models.users import UserNotifications -from lnbits.core.services.nostr import send_nostr_dm -from lnbits.core.services.notifications import ( - send_email_notification, - send_user_notification, -) +from lnbits.core.services.notifications import send_user_notification +from lnbits.helpers import is_valid_email_address from lnbits.settings import settings -from lnbits.utils.nostr import normalize_private_key, normalize_public_key from lnurl import execute from loguru import logger @@ -20,13 +20,13 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, Ticket - -DEFAULT_NOSTR_RELAYS = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nostr.band", -] +from .models import ( + Event, + NotificationDeliveryResult, + Ticket, + TicketResendResult, + ensure_ticket_waves, +) async def set_ticket_paid(ticket: Ticket) -> Ticket: @@ -39,7 +39,16 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: event = await get_event(ticket.event) assert event, "Couldn't get event from ticket being paid" event.sold += 1 - event.amount_tickets -= 1 + ticket_waves = event.extra.ticket_waves or [] + if ticket_waves: + selected_wave = next( + (wave for wave in ticket_waves if wave.id == ticket.extra.ticket_wave_id), + ticket_waves[0], + ) + if selected_wave.amount_tickets > 0: + selected_wave.amount_tickets -= 1 + elif event.amount_tickets > 0: + event.amount_tickets -= 1 await update_event(event) return ticket @@ -55,40 +64,12 @@ async def _send_ticket_notification(ticket: Ticket) -> None: logger.warning(f"Event {ticket.event} not found for ticket notification.") return - subject, message = _ticket_notification_message(ticket, event) - updated = False - - if ( - event.extra.email_notifications - and settings.lnbits_email_notifications_enabled - and ticket.email - ): - try: - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to email ticket {ticket.id}: {exc}") - - if ( - event.extra.nostr_notifications - and settings.is_nostr_notifications_configured() - and ticket.extra.nostr_identifier - ): - try: - await _send_nostr_ticket_notification( - ticket.extra.nostr_identifier, message - ) - ticket.extra.nostr_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") - - if updated: - await update_ticket(ticket) + await _deliver_ticket_notifications(ticket, event) -async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: +async def resend_ticket_email_notification( + ticket: Ticket, base_url: str | None = None +) -> TicketResendResult: event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -96,11 +77,10 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: raise ValueError("Email notifications are not enabled.") if not ticket.email: raise ValueError("Ticket does not have an email address.") + if base_url: + ticket.extra.ticket_base_url = base_url.rstrip("/") - subject, message = _ticket_notification_message(ticket, event) - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - return await update_ticket(ticket) + return await _deliver_ticket_notifications(ticket, event) def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: @@ -117,18 +97,144 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str return subject, f"{body}\n\nOpen it here: {ticket_url}" -async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: - if "@" in identifier: - await send_user_notification( - UserNotifications(nostr_identifier=identifier), - message, - "text_message", - ) - return +def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: + ticket_image_url = _ticket_image_url(ticket, event) + if not ticket_image_url: + return base_message - private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key) - public_key = normalize_public_key(identifier) - await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS) + return f"{base_message}\n\nTicket image: {ticket_image_url}" + + +def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: + text_message = _ticket_delivery_message(ticket, event, base_message) + html_message = f"

{escape(text_message).replace(chr(10), '
')}

" + ticket_image_url = _ticket_image_url(ticket, event) + if not ticket_image_url: + return html_message + + return ( + f"{html_message}" + f'

Ticket image

' + ) + + +def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: + subject, base_message = _ticket_notification_message(ticket, event) + text_message = _ticket_delivery_message(ticket, event, base_message) + html_message = _ticket_email_html_message(ticket, event, base_message) + return subject, text_message, html_message + + +def _supports_nostr_delivery(identifier: str | None) -> bool: + return bool(identifier and "@" in identifier) + + +async def _deliver_ticket_notifications( + ticket: Ticket, event: Event +) -> TicketResendResult: + subject, text_message, html_message = _ticket_notification_payload(ticket, event) + updated = False + result = TicketResendResult( + ticket=ticket, + email=NotificationDeliveryResult( + attempted=bool( + event.extra.email_notifications + and settings.lnbits_email_notifications_enabled + and ticket.email + ) + ), + nostr=NotificationDeliveryResult( + attempted=bool( + event.extra.nostr_notifications + and settings.is_nostr_notifications_configured() + and ticket.extra.nostr_identifier + ) + ), + ) + + if result.email.attempted: + try: + await _send_ticket_email_notification( + [ticket.email], text_message, subject, html_message + ) + ticket.extra.email_notification_sent = True + result.email.sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to email ticket {ticket.id}: {exc}") + result.email.error = str(exc) + + if result.nostr.attempted and not _supports_nostr_delivery( + ticket.extra.nostr_identifier + ): + result.nostr.error = "Only NIP-05 Nostr identifiers are supported." + elif result.nostr.attempted: + try: + identifier = ticket.extra.nostr_identifier + assert identifier is not None + await _send_nostr_ticket_notification(identifier, text_message) + ticket.extra.nostr_notification_sent = True + result.nostr.sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") + result.nostr.error = str(exc) + + if updated: + result.ticket = await update_ticket(ticket) + return result + + +async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: + await send_user_notification( + UserNotifications(nostr_identifier=identifier), + message, + "text_message", + ) + + +async def _send_ticket_email_notification( + to_emails: list[str], + message: str, + subject: str, + html_message: str | None = None, +) -> None: + if not settings.lnbits_email_notifications_enabled: + raise ValueError("Email notifications are disabled") + if not is_valid_email_address(settings.lnbits_email_notifications_email): + raise ValueError( + f"Invalid from email address: {settings.lnbits_email_notifications_email}" + ) + if not to_emails: + raise ValueError("No email addresses provided") + for email in to_emails: + if not is_valid_email_address(email): + raise ValueError(f"Invalid email address: {email}") + + msg = MIMEMultipart("alternative") + msg["From"] = settings.lnbits_email_notifications_email + msg["To"] = ", ".join(to_emails) + msg["Subject"] = subject + msg.attach(MIMEText(message, "plain")) + if html_message: + msg.attach(MIMEText(html_message, "html")) + + username = ( + settings.lnbits_email_notifications_username + or settings.lnbits_email_notifications_email + ) + with smtplib.SMTP( + settings.lnbits_email_notifications_server, + settings.lnbits_email_notifications_port, + ) as smtp_server: + smtp_server.starttls() + smtp_server.login(username, settings.lnbits_email_notifications_password) + smtp_server.sendmail( + settings.lnbits_email_notifications_email, + to_emails, + msg.as_string(), + ) def _ticket_url(ticket: Ticket) -> str: @@ -136,6 +242,19 @@ def _ticket_url(ticket: Ticket) -> str: return f"{base_url}/events/ticket/{ticket.id}" +def _ticket_image_url(ticket: Ticket, event: Event) -> str | None: + waves = ensure_ticket_waves(event) + wave = next( + (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), + waves[0], + ) + if not wave.use_ticket_image: + return None + + base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") + return f"{base_url}/events/api/v1/qr/{ticket.id}" + + async def refund_tickets(event_id: str): """ Refund tickets for an event that has not met the minimum ticket requirement. diff --git a/static/image/ticket.jpg b/static/image/ticket.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e05d9318a5dd38ef26268c0c516f0c057a115473 GIT binary patch literal 106602 zcmex=FcP*_4lP)J;ek6%9GX20;#{yNnZ<83h@b1R0qH8UG()kY!+CWMKw5mjMPC+1NRlSUFjk{~uwH zVFXLVq(C$aD;ql_69@DETMRtRj0{Wy%mNGy4Er4?C^#H_)_dX9(9S+Qs4;V`S;%=_NP?3%VJ;O5WAE580xzf>DyCvhaBlh>v9 zSj5Yqg&69nrzwabuI@Qr#YN+6A)oI%;pwwOs)8Y1kcJAo&vUD ziNjC;^3_)fw0U&>>G_Np!zI0f z8?vPNZ=@d7bklvgc=?JfL&*(l_sTbh2=H__Fo=i<2s1TKWME-nV02(%VP!C4W=dgT zVNkSdTese2(f+o|N#ECRuhEm=qwqk3;ivO+^&rvTl2XP>uPh?dJ{?WJc=_x3@KEVR z+MAzNZ>|@s;B{bNWngfKb!ARuxZ>L&Aj0dM0E#LG2A*aCW>@v>OJ74(^E6NDRxf?q z_4ul(v(VxG6fPm<`ngZmD1VuzDf~^Xxa!E;@;!EUr@wh2w(HfpEEl7Cv(I~{Ni{UE zIWTZ=NHQ=aF)%Q&Eoxvp#MHo`w z-5Z&ZWI;Bj&xc+pSwxe(?T` zZ#wx|hxV>DN~>~E(e0e9d}{xMtoZ^jp7`DqeR(gy{X38EL*LT8z_(qREUcHW#;&hx z_ujf^lFX_#X-jgy2mR>%tJYUDOMLsINz$qpzVsBErmlJSs^qHAw}eGGJbP@7OjEsI z*?Wbj$7CJ<`TJ++zJtrs{mM7qyd379<9JJP?}AUMjyi^WW(S_pc8|GY7Cdq7S6-Ht zi-cZiy-Hk^{_*cj@v_5li>>2S9&}mP?lGUN)mfYV+N#TMmFm9QJ$F7X&zSe1?FL`w z=I?<|KOXtlQh6kI?&6XO_anMKHr3|7V4aiYsc}dy+u!EyImwiwv`q=3pE`Err27Tz zxl>WUVa~~n94Rpdwyj6o3^x3{bNyEInqS^Ft5(!*cRej~=K72IH*X4*b*#@YnI3Su z&}{N5&eqJ=r`k>x>F#4|3%VF(wQuo^jFjw zFO&MQY?0Kl!?~_1X2#22_|>Y;I5+2hn&t{F2E%71j}NKlR@sO=T6}6-X{~3frQZDx zeF?drohgCE&QD%7#XgOGtZ_wH>i)G*)ApycgYTT#?x>yFvF?V-!}DeKo#m|&Z=#Y8 ze|o#*;_QvPl>E+jz4MR#wQAXKbB+7QWuAJ~WpjREEl;Ryy7cqylcTdP`>zPSJZ<;N zRZY^4vGP%0#P3PRRL=WVayofFbJ`a_=VYtAmglE3r@TFTEzWHPL-O|d=kx9N&suQh z%jb@K36;IB5mu7MA-Z+p{jYBM?@EalzrEq5*!ro)Iu`#K8r^$-pZQj|@hgk!h0=Tf zbY2<+7Qf(Mx?1w^yf*RVOuMg@9$&6r(dJL&ne z)ym1S4kws1lozj#Yu0EFmrLIlV|3%r$$W=!MBS{`Mai)|2MpK5SDyMBPv^r`FpH)9Q zbYJ9&ZDhpS6|28ac)3e>-pRD!^X50B!^57(EbTDZ!K^vKaGF6|xTO#G9L0tcY|Khc z2@DK84}BUWc@!9S@H8YyGCk&;vqB*$b-iTul}$SxZ55-wh`QMA_-y;->D1#NzFyoe zF!3he?1h#uW+mHvE}gM#6-TNMTfhm)S&t;Mq$L_SQhhWO*c^Bmm>N=jgC&g=*iL0h z@`e~1Pe{s(8lO&np^J`&a2)W0_^k=l3v!d&0pj z9;@zNoNgj|aqD`=ki)C{jqjI<{aCi})S+W}NAKt4`(Bvy!`pCP<-Rj(r=3lV&-wMj zEP%K8`laumbAJcv{c7!0?e{Looc3~WjcCgC14&$rYi~TieQ9!us1#EJf5+#i9jo5# zs;#~(nZ?kUm~~tH;<~S=uB>%n;AApZny_e-*YAw*EXGyR`O?$w*JZqj&<3T5742dF z()YzoTE=sw!Ypx9w)EvG>UKhB(&y!GS{=JJGW@sIp3+UbP8qyRVNJb$LR_h3SN77i z(Hov@d|Ab7IPIaZ#;0JZHuVeZnWkKd@d|jGTt0I_aFpYPD{*d{Lu;){H(9?E)VaAZ z(tmwbqSMJM+sdk*{+6z9Q{QmH>u{6D$4r@a(Mi{%{kGpVU$wSMmlnNznd(PPjk_qgDDy{tRlEBdkw zSI))S)Wx>q&QG!nS7a~q?eIF#ofha-T^%g>dS2_N`|h<{?>eo%v(-j5@nG7nU&Xoy zmx(<4wbb1Ao%s8izgWzk|7Y+kKDIW=y4mHfQHxplG!Es(uj86E+OG4Z@7J;Y>~pxZ zbi)0feG%_wEwp?$E!n2Hc4oBhszdv3UcIuFWBP3`ue;CNgNj$R9$%O@>upxp8A+46 z*k+&Hy@~}ZyJRnw2p3*mb|Yxomy=AhP2?jpj=NrXm0g$-y~@Ai6pvuZIc`uX14n5{m_i3jda+{t@%kzU#7#p&}epZzU0;c4^yz1HWgj&|mW3KpLd zcp3GesmgB43$2dQf@iZ|9#&8Ks_k{X_2ip;zkoU4yp85r?mNA9+SSDPH?Lkrw|Ku< z>YH}q-K2#@SN6<#S$(y7*2;vF$(LEg5_4BdcNjYF@@vYo+Is7i-`$xB#bMQ-btPY{ zk}atHdgEZm%hTaS_a6G^S0yqQuMl56M}Hg7BHK{Ctoh23ZnE0jzO){E?YGo7oZWs` z`MlC>!8=w;FQz13IvnF@s~BA(>0-5`*!I)&S*}ZO3vtJ<=x#gufXTLdh zGdE21*SYm}GtQs32#$QVbwkcw`^-yqEsG<9GQJ#SopC$M>4DF)_L8|QcT2OkiOk(; zc;MMt_Gezk7ekyH7`wfFqkccT&Q)1`=|#>0*4NLipI$ogHayR29q&ek2HVH$r^|Kh z{Pse8#&M9-OtXdVc*#AVlK5%E5z&iZw>#QC+`4~O{%+pt%LP{>lV2>(p9ZRZs+Y_- zZ*xB_>~z+K_0vi#7BFOQbJ_Iy^VF~Y>!S)ARr`GmB3rjD$x}6St^xLmS+sfH>B<+tfy_>yF z+d=h&&AvMqzYC<#-)nhZ>S(8)tYGmegOiaPSxZVYFN6yGdU)ph=_bG9FGHqo=9?UG zZtguGG5g_1u-w8Zn` z()Yz|mN>IN`Jnb){=$fRa~4{Dn3QbuxprpM+FhVb@?O{}mlU4X~&O@`axLVsTl@l#X48?ttuDtuc zOg!d`^>i!!*UOhVhUR506W*|(_-bat$v{g}>%A}6cDc&3Jmc#Kc>dhK_}tE@=vJ3I zpL(pqvOslEiKvU+&SKlo3GZhWNv~d0%ai))*q6JUuw-~0k_=UMxa_(aRQB9vSNB|T ziyd1`ug2+~?-y3ay^$&KPZ zNmHJEU$)8cjsN>Sd9mi$;T2v(f9; zmY%z^_KvS#aBRJ4!IqsX4434TPP@N)mcOpm)Dx3cUdH&BbxsMdcG~sj=lk~_t9|y0 zomJ{N5j@*K0)i00g_xhB%@a?;3&fD|y zl<>S4&y25IM24=rwPy9#xp6s@G=ie%_zB4rJS`VwuwrWNg=M$sD6ufEP-d%JFTozThm z4Vd%Y({P^UAz0P&kye}>$<#|ImPFnzg*aDcjMmNg_iI7l5IY>f@=_& zxrd(os9ELTekx_fl=-*j`laod&9;DnLEd-kthJ}kKGy6#cvW`Mxo(f^fxZsB4Hc&r z7uW8L*!1Xj*6dA+Eil~^rO#SLSbG1+Fu-tq+=|X{krd! z{MmMPT=*%a#~b%rIu$>ZIacwU+c;vQ+(D%|8+SFa?<{hcer$L2BwypqtGi{Y7?};` z=4GwEu>2V7(zo^f?VFfp+}>^W>b7|D^PLm;m+zbW^m6czNt(@?t9^MiG%Qut%)VZ> zH#+LsYvtp;LX#OM3+MDY8-%-u-DL^nTW52==7#sPIbGV7zrEyDHr_RNDt@SPtm4^g zW1ZJKs&k4xB#rM&Jhn5@l3vp~ss<;$n`?tOXU7Q1nuf^fhU28LT!3Wa-(e_dOCx+Za5v=6_P zntG}+OG#<&g;arCkI#I+ye(Dgx>D$p-n}>8&0c8vVOp|vaqY})rFl2>UGDVTCN5vp z#8mZi$;55d(jmWZg{@q_*1vV(i!P(h7s|snt~|ib{o8fseoeF8+_$g!%d}+ORtwpn zaMgSE`ppasxp$-2EdB0%bzk}3I?lf$Hhe3-zRUc+al6i*ZO0W-i!7G#>{D4~>dRLU z?jAOmHJE>$%-NcEPu>KZ&W}AH5_=fjqc{{f<^9=fQ8RcdV}v)Sui?3QrF6M@YOYsQ z&A(mIaaw!Ic)5#AC+Ao=Py9KHZ~0nM#Z zJ+94V`Q9F`m%eWe>qkG=t#`gIy_->d zd6&qySM4^IO8U|ruUECXZPWU?d=b~FgPBL4cyMuLy_#%r^IyY~WC@u|EX#B&)#|x7 zh^q#i?>(VyZg= z%DwEnVBUnazql4j$NZf4tHL+wSJ>BE9ha~6y}TtnZ{@P!<7PLjmOpzj??sEf_Dr>3 zI+-$$q|7V&mfq6(*cH|HV($8hs~($eUz*{y{G9ndJL|R+k`hyOX2`tGyLzG4Kh5Q8 zZ^NUB#!+=Gi%op}tO-w^fR+m=pR zZg9VU-anC3wX1?_{`}ScYo5J9VKalsb%U*Qb)By2W$c@*#3r}0#9xqCc}uj{d)|vS z&VM=Y$n+RLU*mQ2#&-LIv5HY&cwNlE)m(m6naTR+Iy+oe9V>aud49@d$@{b4Eb}?G z;F@km@$oghuT74>p8Idv>~yz3=C}QhJzJ2nb+5M5m3LYDjgGD2;1b)sL?d_B7gOcg z>%V*dR=JoJw(h1f52ydF!l|WJCzxZJu9a-%4B5HtHv{kP+i4NkBd$Gs{`u>1yD0aL z>Nw~4-01sphSGVQFS)KSSf#l2_{{gq*WA-wm|1+){hm&`?}a(Pd=2OB+;t}S{Eh90 zQ(oQw{PD{twV+*zwT9D5uWq$qoVj+b0!ZmY#mMz>Mo$mIZVV>D(iR03>d;ipW z)s*IT@6iaIH6h;P+Fj=F;7VfKCpXVU7uO|d={?*y*V3u@rOv6cXRmdAZC_8y5np`% zw!eM*w#v(omoHn(^nQ|mDua6Tq_wFoPd0v$i(04~`Ss7utZOsh_9m(NMAWQ~Xv}Fg z?RbCpwo=urO7_dwn%E*#Zj15ns>o@5Wvz1};kWk3o9i5320nfM;oGnLXS-%y@di2P z5!Xwp+Y7oBuO6Sde&w%k%adow-0Wwc9`SD0f}J0xfjaWjvR9u=mb$Rns;ai8;9TAA z>=%Jg*ZZvYIP!X8tHJxJ?#sOWcC0NCy|O^+_ne?e?|akow^(}!b1a{H{QlL~k52cT zc)%5Q`@{A%!7-b=EiY_oVfBrAdyRd9(Usda+j6Ea{!#Jkr;Y7(=ZDLFUte~t#mOss zjp!xO{f;7sv)hg1{ZHO;+;Zyks)VwQdrh5+@9LbYc=lUgx3uc)V!4ukz1PI2@bCLq zw{)MxV=FiH1d%J;%C(mTb89UV9Mc%r#m&0CZr1Ztr=9PX75!ZDir@H)oSfXw+*PmG zPGze{Oeo)S$T&{pb_45`;Mm8vBVVpRDZhU5@jJ(2_|N=jsIQgW@X`oWfi8Enefah8 z%=L4R^iE6NlD$HxXFIra^V8FC?#_K@Kzf66`=_@rZ&W&Cv+ZZaZ-xa@kGQ0_Pdlw# z6Kr~K!uHz}uOH!Uo+VS@VVp4~Hu~HrgJleiH#Pdte-HJIe4)KM?8c*pU0qoWxnI72 z{9X0)*A(?Ht3Dj}{nFFdxP|}X)%A{3oU{8)?g!s}9^n2VbG8t>-p0Syj>UIXj#WPU zZNS?-yKd*k0*)M}Sq%&f49wd?<>tSun#Z>?=b4I9SlhuCqXo+rqzJKmQZ$Xay0l`+ z+h=ETf0kZlbYNiMX=C7dBXi;PZLt}k>UtN)GTG>#i~iL6Jv^JHem0tUZU4(t-1A=`zUugWntEK-Ta5GTx6chL zyW_o1y;`gJLy0{M*dFOY0Q6?CydqN|G9e+uQ`06&RRiIPffU zJ;toX!M3xh%5Ccl^F5%R-dF2;q0Dm9`UwtdV%E!E_*E~Pao*-`8r#15d>*D); zGZI)2emZfq-_CDpZu{5E8J~h>u01jP}g*n+fpI?^jkH1lJEr!4P z{gY&cl;pVFRgFHXCrl1HNo-i~u5wBEn-qf`=d5{4R>@7xZF!$kZhA)alIe0s5f|-t z<9H*{MxM-LPo$QaTT5TwqHZU6CcUpF6JwD-Xg$(fn)BrCn<;!~sOHcg$a<}89Q?FURv}Rv0HTd=L%=K$mzb=d0qP@au(zZ9{ei!Ea z@HCwJbKfb($rq+f{WoF5pDky#66!Af`nmVZt@|e;0xXwv&Df^=Ue;*7(;dCHf!W@3 zV?G2eRL<;un8xvJhSJ!f@qqRr%opE~3hug+`I0vCPDxpyteJ*E=4w$O6p zU2AC0eyy*Q?YhgW<@V#0V^RBF{`vRM+;iruzaEPp6nhC9<`~#~IakkBu-i1~nD{RH zmk)R4#eOSty=gveDPNxBv#1k2eIlD*zu(y#zkb?+W2Z02O<5Y5G*8OrpWU2OD|6+G zKl6L9(Qs-*d5Z=ZW?QJ$ueqDGUI-L@A-GfE4dRloAy_~6Zs zC9}g$JOww6c12G4eD+&JHb+E2zy7*j_htPpi|jA#_qSa7bn)q@FGFYCTPfbY`j@iW z%iczAkORMouV~sl-)Dp6Qs);{<{QqxStWC~`OMs@sqIpy7SDLwF)LeeerV4L#qGRrXWxGNSYWeF@#&Z&+!Laz%C2PW zH7z-Ipmj&=td-fqcN`=A(<)>xE&O6Cer<`tP?A_|V%|+54#`t zUG-V|xn217+Sy^ZUS-`#zA4~0GeV%0L$LAV{C&bIZ&q?&Jt`t{?0eWMZ>_*~uY|t- zwN^aKWMeAl{h6(|YT{n``rhowtG%GE*-EwG`{p;(OdH>GOA?5s6dg9c8Akh} z*{4Uj3`Lj|jC3dM2U##fHX zS1G0(j6Kh5^<`DE*z$b&?TvHVZ1%-%S;cN}YuC0f=MFcmD>$BAl=-uoE#>Ih->sPo zj+vj@92_asVid~qr8o0Jd_`&Dv+CCwzpm;=UcIC0b=%B*xrSf0%1r;5`zZ}aUq}W0 z+H2gfpn)Ng=W~&|)@Ap5Hj_`}dtXt%v%*J`!LZM48L!?|?jti4KJzu4f4J^?+5%>_ zq6DLgKejuUM@1O!S*4#oC;0C3u=UGIjU*WQFRXp~e*4sxrMf%$^p%>d%(lKTnvq-d ztol{fu2r=>wcm`^E-wQWpf##9&dc0S(_G8;(u>t_7t;fWUUOW%dpPu{ zvBmPu$FDOhXto-^`S4|DiS2nYpGBpg&aVGx)R4|*G*g`ExzHW8=!9%*w%KuC&aI6q z<#8;KJh#`WS~c{>=B`ipN=bX9H1%kOGnwpZ;d^V`vn2>F0^ZcAyQ05*{P!%5HoZWAHKUno_Xx7bn%3SBF zlXvB{Jl_@B^YQGrWTR@iHFr}3Q+F07tz5jE_vy9sT5rC;7u{K9H90#!nRDUvc+b@B zUV+-7`Hwwkt}6TWm~YFDEUnv5Cl@|$>sq_i?5*T$vrh?A0$%c4{yNdI=XTuA;)+Le zyhLtyTuQ52`*hlUi^R*9ax*_Q%kE8Wdv8^&8T#qQrkaJ>dsm;d5x)3!y<_det*2+c zUm0|6{o@-o+ar!WPyUtH^88RFXoe};tWtK(-^{=#Vw}@YCq7-g|2SuJiUYTjs-?lq)p4WES4emem zmt^NP{yx0A-S~cV_^~x-x9aqnc`tjBm**k3#$E09{*r$a_pTL8ys>f5Cd~!`fpcjF zMmu;O7Afd4t63a#J66!x$J4;T5W`m7*t6|Wa_WI)hCGdojE=_Bua+xL%F3L7(!ICz zwO0F*!ew38&x)VB_;uakdnI@4m*_r!vd{SAuGd}N>>HlVtJ%oYIkzD=gq4NiCZi7< zLjY*L?Z}A~R#RuKRE7iT0RlH2odp7TG#hTPu(0xMT%&!#^zHUJ*X_NWPA46G62|pr zMeKXti7jE?;VW0WoxS-%N;z|r&z|LlHQ$ckK7aZ2`-iVT1V0Rzp4OYTvdDH9>#lSW z>0?)av70>?4xU_kb$8XR$4iRx@0#mh)pOn8w*Jsc{hdbr>f5K^_ua@p@5k?-`Om(6 z{I>l2^;Q0x+*XHeRlCV`*SL%Kb#im<+5I)Q9xo`$y}xZ|M$GK(XIDRn+q86jYRvEG zY{|RJcdk0A7bwGI(dDW2)G#~ZwjQg;yNV;bPi)UWol|A=?Q_-7-!`>-w1RVjw65uR zZgpB8uvdA(v8^|^SKZm|_NcwAv21RY?j+G!PczLlr~H_r{`E)oAKRMgzGoiu_Ai_L z$F}D0%D<+sYHT~i%>LAtbzIE~SH5Gu?vjP#-RSdJw+S1D*_Dcnioz~u3z2xnA_jQw=I|keQXYkjb_p|951FI=-bAW(|h)(KTKYP`W zC9Dh#4gm~cf`vguGO$uLYr(o!P}`OxXW351mCRq(rq;wR6im0h^CKgId)4x_UviJH zN-f@8tM632EooKKQ|0SrkndF_8I$&UzZpB`|#@FneW%GeqH}^-K{ovZSX{2(3$kQyiGGV|M;8n z+NT<%QgIJmd-eL=XR>`@{6y|U%RTh^-=Xj~TA!DE|E}k~COd|SwSS-H^*g(? z7Bnz5FfcSMXkY@-3r==>X2-G?H%`czGvRfB&MiHcGm-q?RcD0AJy^2lyxMJkuTQ?L zCB2y!{3}4M!k3RNI!+m%$gN+j#|<74pX9SVE^pJ#%|E_ozRv2p_0jguh1z9LI!f<< z{BZy6`=_5id}ZL4uD-nfWKF083xkLNLjWVA0}CsI062U%HSN42a$Uc+E>3QKeZ0o2 zc<)^|bgQ~=e>pqr$?3$Wah?nIOuYn`R^3*1m#u^h708?_ea@{r z+pa%s-j)?>_Y2=WBk3z_bojkLm+EYBk*KnE0TFQ2s(uV$U~~j^_ZS&iK@psCbjj9T z4LgH#n`2im;w@60&D!zE($?rpZ;UAa_LsBGPnI3Nw0F`$TjeOwocZ?8reF6>GOE54 zuvM{Wwj^jeH^^ssUf!mgmw&v?dhOMB>!awzoNV87--6_WwYk>*>ASBe6xK@9Jsq`=4P9jIX16v>Ij3!>p?U5@qUulVsZC! zcg*QJ{^cqA`6-(vAJ5h~mh#U!a@VEB+hzRA440bN?B>~TvvTiVugkAzPgl^0xP5O< zo%f>y3=0?-6c`w|7!))Z7rM{^7!-_>E=65;jCm!=-1v32&$Xx9OY+*|wcJ~Z?yi`-+b43K@3y$9PbP7_ zM+Uc#*FEnXeG{$@+41E@%b6rchk;M_fRQ`0VnVCnL$$${ z-`AHag;#vn_FkJC!_?Zd_vP^?`*aw%z$Mv&h6N1^m>3w07F~+E=@_fHYStSW!5^1a zep}d6f?2{gsw^=;4?%zLu z&X(MXTvL99R{XKqYb*E9E+mVA>3F=z<4>_-4xp+BRD^(QAqGjoOx2tP>slFXwI=_u zsoiUL&(5SvzIexEA$}dxQ!Szwzpi)q`|vAh;5BBK<%3V(EpjaX<5s+`e=z$70|UdAjn~gw{GNVv3aFxklv)k}j0|i(kethEAi+Du%x~>CyPwMv zesx_;b6&E#iR;C$+Y7Q3LH5jjval(Ai}tR+E-_{1%U<}^sLnX|=YE>ruJ`g$+x>le zDngd}XecnSMNQNDu{?e<$Eqe!_0s@OH(U%1%u2edYo~~wYgmxOkj1EZ)4*ff&bfLs zodv?(!~QY{@~x{mUmL;wXm(e2H{)u)NfG6C@B7Il*;Hk> z6fpcy)`|Ng=wX5=&fBcpCLOO|oVORI-CFyxC z66HDUiwjz=E}x}8_e}4kj4jzSn7VhQyjJ5?@Li;1`J`&*>Z<9MT{rF>o!i?WV5cRp zId!Yr*=-?D`p(av?6IcNyyN(V&bqgEH$GMGJ2oM5POnYdvC<>=d^au>OwG5N^hP6E zPgA+fBVr3L-^R7oW|N+Om>)D>Q(mb5`SS__(|YL`|W>I)@d+kRLr?_ z$ksU|D)i#aRV*Ln9MuY2vu)<*XXUNB@%EyzNbh6z+DTcNIqfR_-rsZm&v! zcD8Z@2S=L0rpnWKmo;mg^*p7D`Rn|Ya(1lgzr26?LiZ`5G3Va!J`Oy5)wSC&>gyHD z;2Y;NYtmoOx8>$OT)Xjl$c=5m8y5U$D2D^WlweCjmR@nyY5YBo}RaN zGjr!0#R&{C9ErtK4lqtEP|$PWVJm3!;hgB`%xozU!^Jt_08@&AMvZ8t+ucHsSg9!Q zIIYYprdgk^&%Z0w*!W>@z0=am78CQn@FlJ7*qy?uwPy0odBq#k{1z-+)zW@lb`wL& zmD6gL$1IFbENIxl(`GbNSi<29gM&{OXhxW^f%QbPS}D&AL&M}5e{Sx%SmqdH6}nV; z?e*m9cgr4lE)|ygF26#-sP@~_+NSM?U(OGzUHhCbZqwa%wlBYaGAv+hG-3-l=FqT1 zK!m}kjah-afmuyqhY`aeNuM@nrU%S2Pgqa(Epd$YSD4-0|Ka}O*ZcCH9LrW*6}F&( zLFVfWt#$qLZYGQE?$zsk6ML0`r-8@O(OH1EIg|m^>}6$TxXL?0M4ai#5di@aqXRPy z8E*PIT({Wh6n0)CMdNMH1qS6O1I7 z6&x6O6t_hlN-$!awuo`UiCGPLE+=Lf8F%(XIA z!CioXk(rr+jh&T^g@uiYiJ5_!iG`Jck&RtIP)ImLL{v=N#MDedQOPJs+0ZyRG)2Y0 z!9~^F(TPJs%{4GJAkjHl-L3HdEd~xoMg~S6#_LPlJ9SQnz5M+(xokhn_x}tJiQgfDQwC&2uv=2QzA75>E*HnniWap3hr?x_|;D*}w6*gs`9W|}o+3q&~ z{aeQW{p$_?`_~ig>tkE~{w)(Gz`aUXal(HFgjx3YuY>v6tupV6lTg-|@6S0PZld)* zJNMebkBj3NZ@cEn3!j{w8pu?2?c4n;n-o0nDXp1u=J|$;oO^e<&YiDvO76+J^1Js7 zq?%MERyf%u^c|84n7>;j_u|6#(&(fmUv^Kp75GTG>-M7W+o%3Zu6m!Z{pUb8{#WMR)aXi*Wz+6Wwmf5}gybrdYMU zd;jJgo8H}*y>vz8kU%Hv*W_*QXIr~YyYk4YC|S4oQ)bka)ztzz_PbP9*)E!I!(I6I z=bBxHb9^6O{IHrW^V>PuobUC@`CrVgZohdh@{M+R+4kLBGvs``He3BuJ+UdI__BFy zTEX{?RtGqL)b^N$`-SrFuD-SDnS160``r`P#2v{=nz!y${2>;l6RwkH=lH4fUT7}Q z)BScU=9JZ|M1eP=`|e3uU7UD`{r7){ADkV(J&(`a|9gx30!?hEI=eEZ9^ zZ?_&Md%A5AGz%)?DcP8^(=mJPygj0m+h_WuyKtS2@@4Sup0H%=j{UW)3+L2vOj(u6 zobtzCe{YG}iEXcPH{G~rT&k(}BygjZ=edhL8-mTaZ)b|?Utg2_?qzI4oDa$;2?nb|R9QZy;XUScat?M&mbkg^pmRQZ|_i(2MM7OVbanNn?FzirdnZV%v6NTGX0*-Yxq3E% zH+lKa?l8ZuySZnUmc5;?Q`J60=5*-{jVRst^83DXzbu;G`oqh~W2$P|rpd+}ujR{T za!g&Tbg*RAwM>m4e2X)0m&_??)D&B>Ehoy2_0%E3E9<6xJn?p0R_Y0%de6%{?LSOhv75m;@xJEL zm71G`Qx;wGD)l}c5golP{5aF?rc+P$-*@?AdWBi>glFUK-t7{G5zmeby0pH3KJT@` z8tW6zfpIHb)9XT{4*pp(YvQZD#rwSeZH#EUblfT~GJLa4WlwgWb;!CS=b1llHg&SQ zdvro*nAEM~(>IDnK0kA8!NzI&j~bpH3NLbZYcenwj*dGq>vQ@R^S#Dgh5Kt)AE+z; zs@Z1{5Vt!j{MfO@jv9ZHN>gJ6H}*biT4=KM&HP>WeodS9_}+t@^_vrJFEW*~zqUSG z>-wg>-)0_OD#CWAZ zN#Whn86SSl`~9E6*7{53YSC_%#hVu1`+DJGcbdkX3=vMNdrMx6Ub^V~=be-F? z!nFCPXV#Vevha1~C*!YFA7682_i>HRFpXc1H_vB^PV_wMyq|Z!k6g~-C+yqrH*VYa z__+9*-@mz1Rdv`_NUP2}(5Bfdb~A6?>Y0~QcAw;8{j>JWGN#E%^=&`I@BLfyXY1WW zyDZ)c^{?wTAG$?_T-Aa@kq^H zs`RDdYvKy0#5YT7KA*p1GttV=d!@+hi_g}GAG7*4$1G&?>;DW}H?%b@Iw4ga?DEI- znsV;uwOi9O_w)AtXSgg-ByvdYl$LHqXyK-SmGWi9PaW6j9&nX#nXQB$T=K7qJ z6}6`2uGja-+1&ezw)d_57Pjqt>E1cdk8NA{%b_%@X;Q(-4*{%iTpl}bom6~Ws6DIv z#p5u}$FbGs+iu6TwRDO(>29=|`rd18K*Zq_Hw`VV4wr=NNq9Q*4(qb`c7OzuRd6LNNzr>xe2iHUum)~#NWJZJH$mlDr4UhQ(TJY}`o;&`Qd=q1B* zfmaJZ-UAb%S4(=zrvS8^-_t4|VE5jj1 zKxN<>kArk02(UFJJ>?+XGEZf$dw?{8rBP+z*4_)eS~912N@);x%I4_RY>r)<_e))C zf64f&x$fcY4@+2g1=^f=t1>6%(JsNP@)w`4u|JNjH~wqCaR27)>aweIe_Y#Q@4>e= zAgAx0gRD$>RI!|R@cquum)Cp9ZLpVm9ad|v$d#tqTX=AL-twQX-d_1`8@p@X^B0nT zUQFMbCs3VixkK%G+PaGu=Y<7M_c54Tadq}S3F}h(^I?LMy-uH5!GE{VdG3=SgLAW# zERVC=?wbGn^>*Vwf6HFg&tG@f|MS$|Ef0L*_Zm} zw%WV7@7oO~6^pZk>u?@2-MsQ&-R@HR^Vjp1|NQm#%6Iz+o-Zwz?%(qGlWkog#@qGq z(i=nXOvR7awz#V5y59DDf2A{a>-@j2CC5FAb@Tr?3Vf7!=Wtswv)Jqh=Y0SDt=iIV z^P6MAP6OHhtQzdJtJ1U1$+^zx_+BXrN=W_gi`GWu^RN8JJNxr`zE{3y`@GkhyubK- z4gaxSSC{GwO1@dpC3R`zs+Tb`?NKf!<^SGXzVb$hH>vu~!+%Tq z*ZgO=?EbAJ^2esBOW9xhJ0*3$ao^Z>QqQV#-P3=(nsp|ze`my7^j`&8YVW}xB7J$w z1lHNpUiiFb=XnyN^JrI~-(R_`1AnsDMg@NqoVD>q^!XECOlCLweM#G}O_Sl@QN1fde6{IYD^t}PI_**Wi8#ukRM%nWOe|s;} z({``V|J9i-an!wc<)yZ#dD@=)a&Cu7vT>e_?tOQ=+2qLM#gFp7S;Vkjv7deXT70*> zvbJ}_3*OxuD$g^`;a)uF;NEZd7JO(IE__j;t|S!k;^}4gwf*Idp{p%Fu3b?#L2iY{ zcmDZJZ;$WkIqAH`_RaozbE|BwHvYICwPooeWz&r3SEf|gHkLk+n~@#4eC3nUjQdx- zu5Q}IZ!_`g;_UiHp|z3W%buD>ZP0$7th@Ck`(^ek-Ea2tKk?hE_H%P=l*#9I(RsgO z@2#je@4v44OusZiZYJBF_n>0cR%*jN`QY2d&*zk+8|CE`#g>7Ji`AgwBGdbbTh_(v zD>eQzG{jc$FO#~K`KppP%KR(W)+ch6%B$V3-)8ZJ+RI;Na64|oUZYyi-!ECk9@Uv# zm#CjHYy19pYt<`v$40bVQdAR2{!z3&TkCSc3+ZXsk0e}~J2&Fq6&)i1HOH3og;h)T zoP4)d{#T0B=k-#zK-syqtZWnSn>(vqmp{H^Ji+Rm?ZsQjK~kFW_{vm$gL9@cuRT5R zwmR$R3@tOp37qfZGGA57g57xj=aas*(v^aysd2NXTu;)w_FXe0(pOK)!Xm z?|oe&?;{^~y8hACcmElVCSCu}usrFt$A;B42mBl6KFT^;EbJ(Bpm@s}DJ$-0FRX)9 z7F}0;=3koe`?A7ySYqX=p1%Ijvcvb>k~S_=dKrJ-Z!a{d2K}ix>lu3a)4i9g>z{{- zc68q7bA8wn9R_xt*;gTG2;Or62cY)f+FpsD&Z{o*FY{mj{%X(dfAhN6P77V1TK#tV zL$1^!yAIbY*TXC)TOGfX>war%&!l{vvwSo4YoMW4l_K@I9};SZ^X!&yow&I!^WvP7 zJ0GumeFdIn?T`0duPtbBfcGe{YTtKJn_}{uQC?6+WMojWYYB zly&N_x#oI~_Jr4RdFej0XWX6N`PX!*Y1-XM=8_G$7w7-}xcKTzvD;P7Y;#QB=15<9 zsOVfJ`s(Ki@6~~s+iUF;ZMgW%3(jy|IJDc~ zrhM(!1o8c;x8AO~d{xM5OMd>Z)~HY5Opvdnwr2U|NmHxjZpY`YX8iux7m^L;m%ga` zq8@WE;u!0J+j}>bH_mgEzkBJO_NE!hDoa(H7h3F_@bN-R+0^XZqalGJl4joS_g8tJ znRo4e*{O@)mo=`p`nYyQ+yuE5X6)4p+}mZZ^c-~d0cX!&HeX@c6OiOV#L^jIe zEALlG9@z(pVN+f5oB&B^?tN<`@}39(k+*rg*m82=ai!_m!RtMw!1dLXJuYc$WvV94 zsbd6{4!M!bSE+zX2d?Y(&nH=J$$jE-b;|FLC%yhNMEURfoz1ZCPUhD4>pkpd*lpNd zaQ}l};Dt|~g*B7(vKi)0oL2M1h;K?srv2GjSrf|w>x%+2$xe6re-|4KCbgv}@Su--i#vzE`A`o;L!*|l2^T%5CLzs%cK zr;By>k560?G4YT)?*xxUi^C#?9%V_am3&v|-+a#e-}z2IrT8s-e|eiTU0=*^R2dbJ zvi6(4aDG!mk(rhk>xz_WonP*$^R2X7uGIHuuJb-K@72Zs49gtBaaq?d8?{hU_D+YD z!IUk}V(gah7k<7xZUWTJ6&+`N;&=9%OLk6S+C8!C-+zWVlsPj-fchCop>->E$Z}Nqj9Z8J(eNj#{Mo~g=m*A4jwNtLi zetsSGcDAl3pU}s!-^CmuJwmtb%(tu&x^2gPeQ_e+#UE^fnrD>1MPIj0y=h;Yv-sHN z;_XXJIWy1kvpvrLP@{d|QEuUPS8Jo0p{i1xX|7oxtq;vQv}94p>IGh9_g;jquhzQ0 z^=p#h=}Gf4T9#*-dj6bzYx{qO(#5ae9&26?u3Y-v)|hH+vE6Q0`Td-nr>uJ1C(Zkd z&qm1imag`SQ!>7hb>_Wqq6ya-AA62HSr`6=r{1}IM#{>~_we&oh9ak>yZS$@ahe)+ zTX)mF*V)@vtjWDI@93KcO!hL#@|(WNOZ2NRzLC3ej&jA%6TuH>tu5K}^zQo)=3JSQ zhfmkMJ+O+cM74U()+o&>f2#j86!^{7bCyX|-doDu5*d?wC+_N-$J@X8o!mX;-Z=;5 z$a8`muYZfQ{k?IlaQm*{y(dYbaG>9x*epP$X@9N0RIXnS(-$XAGOpEFWjm^HMaXc+IoiWd~>qU@$wcd5#uN|v7g&bDBh_js(<#sLa zeNt?y#CK1pc}DKHe+%|V?RC_9`AJH9(`w!QO*I$3#3TjSSLgngT-^4bp^vHiHn``p z_;}bF6AvG^Uv@QZzK5Sblbrf31l0B5Kep@IHQCRnbMvlrc@?Iz-A=i;Jw1BHYx@~} z^4eQ;rK=Y&YUnZxSMknq3=)~S~EOrt@~Z%oF}*sd9JG>^$}(cfF$4 zExlhEn*VQg+WFGIw-!d1cx+qrd$pqOJwJ;(-!FaixWvl2V|z%o(s9-1_i{@#okOQ| zzgsOc>vh`gr5`O*%pD6a&A$D{^xn3d1t+c9AM7;JhzyzVc4MmN+FNStY%du)sOB7x z{m-!Tv}nAr5o8LA9#Adh zenQCfxP*q&mFEhRO1F1B&GcGddhdJaxBIbIQ*J-nJ2~0cz4>Dt+x4^$`UjJ@hF;Ni zV7fDtQG9QrfMRma{3${D(^WpK<6g&NtCTp)EO)VS-m}|$f|L5Y+@yY2$XMO{@W$8p zn0#A;d--9FrE^L=j=Qd%ZQF7Db#H|^({e?gGr5ru)`p66Q%iV) z3!m>hxQF-M?3-b?X5adD{d;`wzHgg1>6#V9>b;yeQDx?v2`+T8kSFVp6}Y0 zR=uAcEIa0Dz5O-?8hzjRUhq!ddN4rS^7P{9-xIdZduO~@J9pMrC!Oc&lZ(Ia-0>z{ zt*~~v)@jeJ*LJI#-CCxbb8?2m+=^&D>qnNc`($qZXW-p%bJ4%QiH6aWjxdUt9!(X} zED@YDSD~bwDMMBA&>O?X*XeQJo=awWZhE`1>~^M3Z1?Y24|jKyDUpxn#OUe1Nz~u5 z*6VEfkIiN_oGccu`(%TB*L<=)VfX9irti1EcV3+Rqi9Wd?i4Sxf6qIn{k%B2x@6mm z*x6}<%l949OxPjYcy-%0Bknh#EXIBD@zu75jIKwE?q^Q)+FA2sq1HC8xm6BVn<}nH zO)owHJ6(gKNTke%l>xO-NX^zq5Q(W4jyM zwfmmF1xF2YH@&^p%C&8e>vs1Jr}w$jPs*08oUZksA%A0U^AcZEzw@PO$M5t?WZLuo zlP`66(#P<`_wKr9tSLQ0zjW@;TrW|2F^%!dGu<5*#nsN&ZFbvKxb2-(%XBkoiP+s8 z3Yo_^>)*x&&k6J0cYnjiH4Y-;cP*x=@^p!QZ(F_8$7m*7x9ha(8PR38bGN2L+e-c3 z7D;asMZdKrWXr5REokoh^7`Vl5$e6As{++xs#{F`KF&%w5_WoTaY}GZoZ-6DU+T1` zycn5|2!monlMZKl^5o zkiVxWH*&u~pJVRP53j^5HtDxCF8e*zpk!N5nd{$MhoW0nDAoORi9F)HI{L;5x3vc) zs&flHck}KOUe`KdhySC6+zXy4g#NqA?HYO6JnZhSV_v1*ZWgzznVZWcEOYmk`3ddn zd$hDe=&AM6HP4TkE&OFuwTNZ2r|E`COYa-zDY@QRy>-Wy_ZOdmdYM-~-jd|5*`1%9 zqOyMnlb6Q&7>hs2T3-|dvVX6pYkS}A{|slcFC~&Y{yR>fCw(_$0UJALHsiSSQ>iffYfrdx!eSa|P+oD5O;-_W{9bpuFr*Wmb z^L4{N!Rc9i4lef-kEh<8yu?OaAUYRTqO9i4F+BQla(1DSW;y@dsk^?Zz`M>S%2oz0 zESe=eHLJeQndaKecOlx2PxZ}gwcEPcQP+I0td2C>HD#lQ*DkNAibaNbN>{9{?k?Jz z*YRL3-}XD%wmUAW+Z}sgosC+f zu05Rha?|8$t=qX%4=%D#UY2>6e?$JmN8xWu(*8y*7cc(fQWE(jr@FbUO?76NgTSKo z|IgUFSn*F^)S0L zlbMdu$z7>(k!8E`K{f9|o9wljY1!L?XY?rE~r>SYNafnwY*)D=*?XV~TpV+kxyB-MP+N!|(gu{u1uSx1(d}Y0ChY z)k>4w_IqAT{V+v4>e}{Qo62S7M9-byckODsV);z3iJz_orhFA%`=236cfZ%f9Q}Qp z+{JRaUi~|i+_#OZa`!i0rxz>CojpZ;*P?+I#NvZPr^pt_nOMyEjsbd2v^0 zb6KqXqFtM3MJq4dB%|ZCp%k3qE zE1o+};N6k5wCg`Nv(-w4J>7nCHCJ$<=BYD@3#RQ|pX)Et_@lz_;t|IW)3pBxNtosc zO5Yd0ns>~_tV{ILRl8qj=ST9oT)O__Yt)8qG2uswR@YdbK6}wTV{Z7>ebHPmw_ZP( ztn)(7-)?Pu+;&gqiKxSxr=5B3**m$Fr}9~O=5ny=DyfTP#+|-3>F1{F zC8`J3EtSaFc6BZ1mT5sJyYv&*o%8wlFn(v`oz<09YqJ{rU+uV}Sog@!qjbv5v)OOu zR);R>oAGqRw&zTezs)>KFV8ONKDaIG?Y%dh6;8p{Vt#KU^gCu+TynA8!M9}9yV%`@ ziFaRe>?+l{^Wp4G#cdDaFD+->{^8c-+^133mMz*`c1Wo1>3@c+IRnkEy0!r4wG6-MysKt@Uox zs@L&b#Z^4EXISie|1jWTe!dE-n9)fB4tT$ePnx2yqT(bEophq z9JA=$qrVLTTBh4yy7N4jTYk^l4({(yCY8Cf|NgshW@79#(UsiHt&NBHX1`VG{&dnZ zxcS}9B8`N3{S{YrLp-KCCbchdJCJqmPj;64;^V7%(p20_=1zN@vN^5pNpD#m8*E7E z(%(~3x~q7WZ2uP{^H7QZ>-HPxa;N|HB!S~p2;=A7#O!tb~51nI<9Pk5rTdQ(RH z48~a1*^V^}xH2a3MAfJi2nL@y+FTuX;C|8epUKa^?Q*Z{(Y~L5|LyIck=J`5nSBjQ zo2+)(bu({^@Z-_m>TTf6&i*(y+w#a2ZSB?@NsQ0Fw5wm(xK7*6_T1C^>(=d_ecC=& zwcLE#zg=GM@^$`}Os(a8dg?#JzIBbdnr+@+&BFI>Q0P?3S-9$yZ_3OFg}{WqyNZ9( zdN)gcyEaK@W>oynJ=?!tyR$`aaUm@4g)C{y*oHOloJOysbJjNi{Y0Z0h`V>vqpMT^}0@cF?YO z#X5g0OP;$#HZW~kl(HnucG{YCRm&=ErTjzQ1w6d{Zt2m9<@~M-?`-wh^xnc7?3730 z2&YVsJ@<6~I-#W>?(An5O4a*V<)n6K(7N0u4 z=$C_glDOM1!5$d@1bidNgLW@=@;tV)okC3l*wM3Z_Qh z2NnJmudYfy)794RV!gTYmB17!koQ1s=P#VkKX1RXDIz)Q>UZzV4>>7!wlQtfrhc)#2`H`~)rN$x7^lQX4hH)s18=`XeXar!@l!GDHdxBfHC zsQ;Ur_$xhqqPJr6fs)Wym+XJ8JGW)-6NNR(mnK$ij@e*QqVtC-H zJ$vK)VaA`++PYqY?5*Lt)U(+LguV~dljlct(SxoDhuxI+v2=+>$(p``PcYk_n+MN`i$Hx<7&G11;yl`Mf;CHbEl&TX0hq<4koJ+~WW0!w>ZqCG6lU$d{2*_IZ&YSu{) z(G!7OZwt3iyz3O@ko2ii@(stbSGr%47temEpco_H^{V%W%bNIW!C!mo=agSxw(O{0 zS}2QmY>Bwd#?=dUK0Oq?&sn1HQ!?w_&UUL;%MCds9NzDmRC?s_mTJQ(Gs_d2 zHmA0|xTh@0Rl&6J?!`W{7lC|RdMdZgig5ZUb?85X#M>#C?@TsI`sP~w{8ISi!&k&7 z8(#g-Ah6G0KFaJ9>+LY^e+kE4Uo!u>>f9y{h7_Hkn+L_T%VXuw?Oyjrd2+tgt7VCg zq%Ajlxa&Qc_i&eJt^NT!kFDF&d6^Eeu99{$a}W%gtg~>o#wT@$z&V*G)9z<>-%j09 zP_$>-q@vRN$rAq=EVfS4o!GH-;sUXK%leuU$#?b2hRfl5O|w>#yE*CzIU zi}@7!JnRwgv?n{#S|=%&PK$}&Q*w1hWY?iAlaA79nOyQhyzkz;RQ#N)>7A+_#Nuk4 zE-dsZ<&PL+U}(=n-G?00)8jp=m+Tkj4~X+S9{$Jar%P$Z`4gRCzh86xXE2-*9XIhm z!`ZNqSu;;W9OSS3Sf^IF(`@4TLn*4qjwH@9Y(LU8RY9}h(-o%$8i8D^6(zULOkQAq z>u9~iXT{S?i^{cCR!`Nn_@?r&vO1uTIi_AFv#-%9bF25U`g6XOdQ%lYMv88nvQa4S z-^TU-8A{8)_x@-2y;}NY#8bnQ)na=;UYmBnC{ai&N@!`uzu$#ryFgQ_6JIi3UCrOg zdh5$$rPF%;_Lr_~Q*^y#1({Nvo88b{zm)TNLfPkr!_r5TK9;QzNiJC$s%dgca?>1- zxr%Ou%N|?aPw)A|RQBS1-(qX6GZAVV^|<$1{9fESFTBU4=|ECRgU_ZNoy>~6-OD1k zO#kuWEBli-rS(UoL{@}pZOY%wduq``#dqw_g1BoH9hR5ZLL!M zY@XNVa|>C;nl{}`PLz_*i(ZthY9A@NIbe%u%Z*Hdf3HpzpO_Q6>e`o=fyJ`gr=MI8 zXxP0e^KJcl$(rxtsm%%C2qI z+paZSE_tMS@9NTiXU}VlFWYWyUN3bER8b#O|0VWXS?!g~@>w=lK)qG&<5t%fiZvL2*@IAci-z2VmT_2lqVr4+k}-1~2B)X-Pi4$>eb9)G(*q+HJRUgCnN6IV{nO zoNz~b*O}li6T(YpEPtu6;>ELzz5-VpCJ9`d#%Gck#2IyI_5-<@xvQ-{u3afN!8K~J z&C#W)7a3lxsu77~12Xf@uA>=+$mV?O)xiUf=Ycm+)$V;q^%6 zy$5QPI@+>1F7n<~PTjIN|3h2p^-t5buD!+w*0u|3J}HLG@A*A}{!I_uUxw_ip+spo7ym(0()y?&`(+=kN4>zCRGl*ZO; zg6#eoe(__o4Sx>Lqy|?M<_1Jp{i(apLpRlv^dijQ! zo!Yx!*u|Dy=4wxRq9@&0rn@@*U&ap|<>oc3vP)L)zM&U0_3j&SyD<1vgly>5}=ju%E#3l9Z} zZklJv(!g=|!KB1!_Y2WAmfBz&Yvm3WJz4jD0n~|F+Jc>(rI)14erMOO+w&SU0OnA& zSa+N2($ozLoU8Ze^+{@8U&0HTnpyRI>)LJqUY&aWz02(5Inmg(?{f;nCRd-6+jaK6 zS=DRzJr7^6mHV8&ZcFvqdFxfa&$f;71&N#QdEB*U_PyMrrIT;hmsPzs-}CVGntPwq z*KMvoJ8##;cjv{bwoJa4^YvPP<>u>Aw$EikpWM6ud-L_kz0dB1N7{mjz0b~vO|Cw> z1EK|N8_3kx?t310y@s+thTi_Y`Fh0OXXj(nzR$KTU3{$Ut!kV((^V>7UgZ=u_k{mzX%-;?X{rQ&On!NQ2D>Xwh8~qjMJ<2?i_Dj2TWv;eN$Lp8d z0&oA(;!|$Yi>?O|oWlN-s~BIMVKJ6)SvbFs>_Z}tI( zuM0wE_Jj-dOggyi$$ay@_a8{uq_kM?d^5NF1>c4<(b~u7?K|}Sz%y;3u9+8S?=*Yx zpF#ZMGgWa-W@Ft6%w0UpqQ{>WhJU*JcFL+#>zhlRe(N56E&bw>?ms5=H4IBmXKZFk zpUC+7<%W+@2Omw$`!ZdXry$>eO|{YU)UK9{<}P>f!;}+?DoPs?=p9FCniReCChyJ(dZ>!zU^q-^<@_8RC|&?UXrZ)d47ovkHDsj8BMnj zswuW4w~qgF|x5DKmPTnU7}qw~#1Nxun+yX2cLrT=X03!82w`{{+X*S(i1ZL@TrosX5ZMqNiKg`j>Gl*%{j$3m2W3 zCi3WFYtkdNq>C2cB=`Stj?UmbQB!AX-FdYBoae0GdB>^?-ZrbT4LY9&Vw(UkwOH2>GIr83f-+}Ix20pG&?&%5b5q-b6`rg;YYYcW;@0+an!TCvK z!KR#l0y%s5CjYJP51V82%xuGnga;dzohr&*S-B`O$T#(K|DE5xJddSMKTA`X&LD7Q zx7?a(Ri~@#u69-mn8IdXq@VF>1>Jv?UkaK~V)=NkG$r@ME6EcL-4nCC+Ey>k+!dOY zzSO3~!1qzVz{A{6HBG!17oY8NEp=SGG^WaZk=(JSpjHIVKukEq7$>i_1ZJv{ro^y3~YJ73-Wx=H!Y!aU{nvcpRo!X=!ewSs& z8uyT$Zc`0^YMYeXb8R|#BHd%=S;@1WrnioTds%F`I$>snvF(<;l zc+wICe-fBqR`Yh%jhN}zZtWIV{km=S!=Pxpva2^%%CFrT%>`BgGU3Kf{k2=m)VyEk zh96o|e=&FUMkD*^tT#T>uiaYP>iHMMkB9MFPS!&C`$Sd0Zd-Tc#NXUiKR`-%3xoJa zJwftCAoU^rFl!UR=7oTqA*x#cecQStCu?(8T7YzITvIX8#W3!}v?$F0#uaU$lRB5L zKACc)T4aJZWrg>{3W_|NsfwT zNYq>-*Innir|c5j@%aAFY2VhqsbBudtX|)AO6Zl2kMVaG*BD5wah2Odk^tiO59m3+!nBdt*dga=RxkFO>;UOE>Cb3^l*40t?4Jz zx?o+ptA~r_iNh0>n7=(y-oExmcgKelOpN9-FIIAgFEEVVpLI|(_&Rgmv`KH|W<5%K z!ydiUEJtLvU5VP4OTM$e>YbU(di1WhVNx{j>kW-LPiEQQf8poy=-e{poBC~UUoeIB zN88DsIPtik+0Sj8RnyZCOkw8^e5eU)b8L&eaX>KH`rBjWbjC7i$(2Eg`ih-GSDHMw zc^xU7*uDP@LfM`cSL95m%@fafWS09{ZOdk!#K4X%OFT|23fp_-#MZrc zEQK9btn--2rTUmtR>35AyW{4VZMEw|G<9V=Lvto6ow$9?H=8rh+WF=!GwryH4flA? z`c0X1t*LwK-iv2-YupUjyCY@IC9@5g!>b&t3>ae6&PW*tO*y;4u{^M6-l|g-7uciS zbZ0E}OuOTIDPtL*chSzYJ4bRZ?ly_#WiU{g@bS6UsfXQ36L%+Uk>_18L2#Os#nV8( zgDW{0I5(_Od+gA*!{SnGmP&P~?i|M}SItt+*03fzhi=ni6<@ccGO^$n<9ESUUang+ zM53NHRU~Sp_8T3WW2$jC;K8J+a*UTJ#`r0B1$GNOSgXyW&eON?IKSZGQVHiCiGFu; z(JlHceTAF%ZVLLOo9 zB+t=}91NT*)|i1ka!A)ZrrK2}$-zCbmDAx@m)?JdnquEQDiJl-YIm#;y1u!2b*-ke z)A7Q!oB;oVzYhDXj zadIgtr*5^MlPWW{@Z9WmaS@x|?O5_7P?+smz_mNB7w#+0N^gDq#A@%gqfyg>5-#zr zFa6J;FR=ZG!r%4fpZ4DRv|p+C&z_@`Zl2(B+;=5R+v>XMu?wj=_og+MilpW*dd;G8 zP)W8>IQOC9oTPszAI!D>v@hd(kj&ja%XAIjDa$uqy5%@+)4XZ7l^-9KxXF9__QUnN z*8XR>{>k7!gNpo@%Jhkr|9sbH|GoFu|3;VRN@aG{~WC$@hagy|!+w!7GqIw~=^Xk-})~iu7 zez1Mzs(qg2%Xezm+QawmZ{7dd`~LSrv)>Z+6T5%etUod5H^;r(->S>r-1feI?|1Cm zH;pyM+^O}+Ra=wloNk8|Kb8)%Rj`dnWUrWeB<}WSH^rs0g<(ryfkt^aAID}vEU91! zvwa*I`2M8Sv*T=UucfXy5-I-KexgZN+j8mMr4RqP>AwJ_rZ?^_TsnA(c~?*7eGs_ zAxlIytye5x+26pLyw5aL=wCSw<=#^zS^!S-f^RM_UAmG zN51D}y5hh|a~?Qp%7#KLH7vO}^S<1{tnRg`TurVVop+q`O~^>&1=w#ZilLcu`{mK&C5#L zl&4o1%emYRw8GI6mLV7;{xkH3nv|uvZ;a9}=vF%ADst*x^!9V|np!2HVjx?gS@@ge z9&4dHy_|*XPKQoUQVqXf@$vBELOH!Bah{Ku<8Ndg+0zKNbxD=2)IurAJ2NbpcfB_B z_%-YIe+FBtFO932Kc3r~TIcXSSf1hD#{Ud5yE-SlI=-#&kmt&Szk-{jUU+Fm`L8); z`ays{8J-uDl)P8niMb;(YeMXGGqXooQQm813x8att8>AUG8IZ)C)uT=cTRkO)w-?mNf6bp8oyuWFO(f8!?_=jPkjT0AF9K0D{%nMGu zbAB8HZLkTEX>RjBG(k8?J!wb{7*BO2uK-{~%GOx#eV=+BoXgMNp>?{pq8(IdZ9Wn6o3SA5 z^!+?n&alw8wcGZc-cgjE{m4?QYgc2>y}U|uv){7Y!}tCB{R%q3wu;U3u=*hf@eRca z8mh7pCqN7M8efB~e$;b|>8|O`!%1fjAD4Swue5bp&%za}cLmwZ3}QZ_rkc-j#Cw zvQZlz^a|Rz+CPWNMJ@bh6lfZ|>Bn=c3$9A1?(BR2so+0@iujkx_=%SPoH~}? z&w5||Tk?D_c;+_w(>jSg5-w{OP4$|r@#(bh<*ER&*Wx=EbC<;EdA84VwW&Oqapy+r zjhTi8>x6j^ym8P~ao<{f)9iL_?$fxfvMo1Hy7ldy{JcN5TK&A-u5bSHY@^Ej=h>Ei znQv3|`lVjQ*XxsNKE7VRWas1XxRalcuiHE6d2ej3DpYOhm+3ZDuWy2sZK`<*R(3t^ zWbyHJ`zAg2cb{7GB3!uLRpF~@SomJ^+i&lE^IcyWcWukEl$*=082NieMV@<>C$z1H z>)ABb?MHX0ZF{Mu%M~-(o7X(_v)Uq+bh&Ta#jYGTRt{L8mNRu>o435;hm|#=Pp$OY zPfXgh)phZ_{r$5KsO-?Y_$|=r?91x|DO* z`=e`Ilx?SdBv7w+=TI7;RF4J;#Zia1+DN2I1Bl zdsGSwza3Pw`q090ZJUp@!;!UbW=wLPaosY^HBxi0RP2VF-Lc{KJEsY>Y}p<6_E5@P z>BqNszp<_mQ;3|izL2$6bB4V)$ApQVM@{SwS#sJrZ*sl7bH|O8g2^RU+EU9CG?=cJ zinf_bDO>#3J$io2gg>9wDbM8Pz0GYh(fxeFx-*;8j)qS9;>BuSr0%p)T2R$R&+CzK z!r3iv=WXaYofIPO;XQ394@+Br`r2Kq*Y2tQ=xO5IP_yswu~o$ift$s535ZFZJ>quw z)`tC-d!uSRX1}QAy8Y=*&Uv+*bxPACulHm|s6X1b-0nI1^8V-zZ7Jbre|f3R-%=N- z*`3!jQ_|7(h>_y#n`f7)xT_^j(0z03;Pi>jS%RmwJ~ErrpHh+Sxlx?yNB*2J$K1{R zxt@XyPc*GWtehO+tNMba-o#ai#;kI(wN`< zHWw(??kLRuedWGtL+12@f-B477VuSYE_Sou&VSS6Qt$30QBzkf6LRoj$T?d64s zA6?IV`+iqMbj_z@Qw!FzMgdP6X#lx_G^0II)1UPeTrdwr)t_?6I_sa@XqRh z&DOj-jx3A*YHZV)z$un{U|D79g}VxMSM_pDCr%0pV`5EU67ydbSJEdhHs{30rFuU( zZdUHxw^Jkh;Dej<(w3Ne%ro2g-gJ^!q!{nSgeJ54)sOwoH}4N9KHdH}>cwNuivsB^ z3hakmV>$2Nzxh#9EOwjL?c8-&XPvIucwleFd44y2?o0R8u1z?RE~@qDh3GfadX+ZK zo1aBbFWRm4aq8YxSNr1sE$mryO3KAjWn;&|Th6(;vZ9k)WOr>37ge$h>nPAKO`KGH zBAV&mmg|fcRi%8)+>W@OtGP=Y{oVE;ciqG2ms3&IUoIVZdDJ$-7eOtSAQpPJ`Wv46KPG-E6P32=x zoG%Xi3ReZTA+r^37DPiL4ppjP8-wp$=6dHFuMRG(n2CmVLydXzM43hUQ_ccgmvZq&VFSIQdR2Irx`bgr& z#vNO@Q>VPYdD$dN;;Bc8cHZp}tsfJ0rv7I*w13}gb@A(qJ8s*bY})!~-nGO=gEKx? z)}MQ#_0CCn{={`p6fJ*pr7ymgbDM*y;?avY)_ECMZ?x^X_Kp!w~O7@9Wi@$ zKDHzGtlVya>$7ajRJWT{y>X7q_?n}*9>mJa_=?bwB=y;S-H~n2`XL&4ZqG6;liY5W z^~M{dY}y^Dxe#ThAZ6Jgb8jP?3pH&g!utN$_S~~ly9GeXP%M4%gWI{+DSErK^|J2g zao0;e*1W!T%-~qX#WQ8?mZ9taA7StkU|?ioV&ULsWn*DuVg}!W%fu`wASA4)Wat=J zRJ?H$i>QK;Q(|G`!HW+?f|4dKRCXx2^yq_uanm8^fZ*gwA6XZR%d{8mZx*y}#Zn!N#_Iji#50ec{iQ zlAe)@2M+yu;`1+t?NR<(JtNKq42vfHXSjdT%=n1>hIuUS;#-kmTYPIg2*XGOjjRfV z?bmZq{EgOwb6+_+=Igr(wl3dmc-22+xrm!wj^m6qYrD_P5B_uU)bV!3LyJ6wKg#*3 zY4lA`IM<>r!TiXa<=V~de_9Wny?f&Dran8vLLc+eo);(Frrq=3did6&hG!Tk^bPC0u>|f=)GI4dZrHe=NOv9@)z7;PN?QnVF`bqmh-)M~4_zTlKU+ zp5H4bRg^qkv~a>h^ELKwL+@{`_TDx%&pc*r-$D0n{~1cn^lr>J;LKf^^&|9wuo|C2Q#?)cSLJI-|!NPddY1RSS+%%l6~nUs46L!g!?*0t411gyc06J6 z?fPA_#dG{5LRsGVCajL#6tKwTN5w)BgZhxYfr`z$|6P-BlDTlrN8Oxj>v5^hL*D*p zZKtMwG`;_AgZ<8k{fS}f84Ery%hw7%7|Fi(#E+(GHAmx^ICG5>_wc{pGy-L=nJet; z)3@$All#>1;JgynL!FxTB^S+qXylb<&hfl>$Ng$_Z)>#loOcV_td?aIoJsf0?9z!@ zbKK&z)(Pfkb2(1B6vtFAf3|4S*Y*dk+1>w?+N_pk-?rab(01*;#5MNs+Tw4tj=UA& z$vl>udCD@ZPP_ZqQ^6$D^?6QlP{5jh!&>Dhiv+dWjXDK(_#W3UVv#HSv`EM3qRnfMpK5-ko@ZT?!}2Wlc}X+Z zdC6v8x9#@$scyH8>07s_-%nq|&zVKtVWE7P%QtNCl_)LnWtWfFJAbsW;?BZ}NRe_h z#q2-B)SExnCrD3`XxsmOqf^JTjXN3U%EY}rn)=vfqp{4j>8mb%jfz|CeKoUct$ygz zS6lXmQAtSq@VO|RKjGrNr<9Il$K7f1b?ta=cvD8*U3_<8B=7#GW#BLqSY&$MQc7+0 zwxu$0<)YJj1*3lb;4S%>9yP5}*V|^=(s|x@Ys7vipA|k>pDJ)itJ`c|$E&DmmD=o9 zp;G!Y&WN~K>}FhdYpc#DgodE=D(84(*G>2G4mz*8GJVpO%_p-WpDeQT&zg?^xxZ@8`ZVBtT_vC8p-;$ZBsdRZtaxiUzctE-nH)TMfc6t*&%9zEn=Mk zaSJoA*cIy#oTno)6evDxKS+#w=Wd!C&Nnm=p9$3?>0 z)0(wT3Ez$gQxV8J?Y`yEJuZ%t*F443CtcK%KF#^Y#A2d4^F*hv>!((p$k_Z5l)+jB z*HwQqQTle}?SF<<7JJ@lD{kwWXx618|6r9=w#z-O-Q})wXa25TeoKB^cX^ITwe`w1 ze;@lUHoL6o+uph8@DZ&u_e82gbRYWe{@yFre{UH$)h@bt^>_VC=Z<6HUV*x$!yyX7u()_S|>@Uf2G%^k(1 zD;gxF4^I!6)b`-nM8O-!Yt7!@GYPNzY6ebcf=Z_AC68Q*=qr|;aOUFC=Y^5HTS7Xw z1ipFy!LiMEjhcU|skTMwWYgH)i~7^*E+T9SzO~!yTmM_Gi?b{3&YbOT^4lTzaOsWP zQ|Bhy&w4h`748dAjET7Id^h)?<=d6F|1*Suyb)&tvg4{Z&rR08we?3`%fS9{wY%_l zOZ4u9#W(Ms{5BtC^qu)>X-5jz6e{!-{I=Q_^!}NnwR=?knbP3AzmHwZ-v_5Z+Oa1u zK*?%)7u~(hDtGRc z_k43Z0rk0av!Z%GwVhgZadzhPUx}p$vl1ppT~yFKF2r2)bt{)e=%q7%+Gdx(m(rEr zHrrnG4RcZZk?eS#MOixTmVGB&L1_=1oL$BAP2+BM-s$GkNC=MneH5CcxBPbgb}{d5 zQ-6A`>HR&KThhK7iR|6F?AY0wf;qzZrhF>Tul8M>R;wi~+y0*+EBF4MjCFrs<$y~D zMVIV(Z+bb7xytQLbUZ9Y2)W->(D<;Z3lj##)+YGxG`(~ZLr?nZB z2j0T-fNN940iX5S9=i|L2-F61n$GR}>{;xwE=5oKL4`(YiLzEsO-PTq+@9oPZL{ZG zRuV2`-&GQAT!n~{qmPR~mF=8f;Ztj+gSUa|wG+9EmiVq+v^MJOnN8s}FA5nLr>w{k z-Lh%b+q)tW7U8SQ41Ln4PrJ72iqVn7w>F!2&9As)m%W;^)ah$%!QSvVE?LDEyOKGj z51aPiF3jpWwKZOQisL8C-(emTTK*+VNxp3PDEiz$8B$ zMcgkQecrLA?Ut6yt$U6~u9;=j>xu8YR`sICafNK?q9<0aTl>#^`ujcgP23;v3ond0 z)^}bFuZ(?sw1#g@zv~ZGxjEKd-)~ReKBe$_g7)9Vq1BV~ZYrO%IKBJjw~pDzy4|7a zJy1+J>e~~|k225BBqPE(BkpVTzKUsS52yZnz3wOL%-(6|AIxb?&e3V!>dhuy_)EWV zUd{f({|vjQdkJs+wI;81{zm?X*3u6jh`u`}r)c=@+05>tlNFO>w9|0!YN#bpJ_r$<6gnF)qdI+yPk9QB+P0#HV?Q%ggF!r-qX|-fA9uI3qYNeb;{m){kb{4@#!FX2-tEeDB+qdVBJu z%PqDK7H`V67M2aUY{0$5>(ZoGPkygE#1(WPX9dJEL8-@z9lp*LD{3^r@qf?#$V>gk z)z)`SXGyGnwNG8Ba{`w%!~132d6%^GgnzRul*~#Ao+qtt_WRe1Jaf?xyRr@AKHE%p zy(wcU(3|#R{{5@&xxbvIZ)m*LX8K{5w~1r))%4^g`^k2S%naUP^nRU5(PYz^YgN-bURJF+lXUL+Po0O;KPg-6-)V4j`|e5C!)&j( zc7EYjs$6ZJoGZ{;R@Ud@!+dYS9_!uW{mN^bvLDpXI`d8E!(IDjrZdk@lIYNS*)jcZ zCd8Ay0_-Ml-L!ijt3VUE+^Mw^8`rFDQ`>y>kn^IepW;og7FA9;mw2L;p_Zv|=97($ z`;A|^m=!NAIm=s6e0N5G;{2IMPb4?*^St2st4J!vBes0~q@Rl~mQS)wl-k#=oW!ZN z!SqojEKH+(+8jd6yV}96)zinCQj5;zM@|oz^`q;D(~mAmhUp3pW#>X7+F!bbUgyy{ z;At+Yxvc!ld6f_KZ=a-TKQxM*BYr46VybMo=BZ!M=GLBTwyh2w&QV6Ebs=T9vuo03 z?rY`u9gi?c+PR~A3&u+bEA&>u-WhOhmt4mJ0tU62e<(y|q^HRR` zbw1hL{GTCT!N$aKn&Dih-1IObci||tOv~t^Gq0zf6M;mKTL)w2$!lxp_LM>^)h$=` zPnvR0POw#4Qc!g{DJ;46_B;QxpF#_A)jM5{GH3jK@+7it-s>sm)BddFSsStPp@qGw zpvw8Vy2-1*1vcydS7H(THVG=uwvgIOeox`kCH6G54{`l|=Q} zuc01O8YiAxiC8gf*h-izCuG_Ur~Kt zi2JROGtJpTPi{hjXs(+^}muV-`JC3$pY6yC+L~ zX-m|A)k`KTo|8Wkvb-g4Qp?YTISW&_9KW~V`?<_ZYfjx0f%G$?&9mAb zs+XogD#pw-V-v}5rnOI%?x;KupRwyp;iSrndG{;Q0^3htwf>oZ-Y?n#pWyBcGtG=S(Ic9jdtY@y;o-zxG2Sa z6!!y_vL|wv{+0_nzeSJ!j%oKS=-cHlD3jCpKyGe)*GplmATrmHRPPjK}wP>-?@q zA9uQLHd~R$w5#pW<>2hl4BHivUR6p}aEY%G8N9kf#@$JdJbbxf2VeA3vJ^-soD@+QiRM=R?=nW&8YGbwB-(?BvTyN{=);|LoAc z6;l0X^{IG}Ttv;a4L{c!Kil#**k|E~_KKA$CiXuI+`D?x6wig%T$}khD*bHceA6{{ znN5jX&;Dl!^r9yFz-LZrt%#-oL4!|Q~SDNM{f4|r~OaePH|N) zWxc$&^Zn+Ok4zys7CtB16f6VQ^UJCpShsb)h5D&$I`7~4$!CgunxpcdsO5~tg>5S) z^W{evErPW8Z_W;u(H6YK28q4pU!|nf7TT@(&yZU8PgUxfWa`h$v9rC_{AYL@rhC)* zUCWuLb^la<|Ckd0v~*VX=B#fMcRfvZd9{V-_4kkZ_m3<{ezRz0+^VyEB5$XO=K{97U*a!6 z>HR9hG?VdM2g`|*DuuhkS!H)5D?r^6P#kT#T01N(;kxpBALVPI8MB{3x{4kB2b352 zH*KrC1xXUqckFUnKk46mmC6mXLw26Lp{D(D+PW#>-7oUYE!O;GDoN}%TJv{pnC?y2 zRo~ikM5>ejGwho6#m!gtJ*qS6NqaFZgB_>Q%j=!m&xlN%_s2+iIsbbsKLjthlr2=Ir1Y?bFM^-OLHM zy#C!>HMWLUL2(<07`m`hrUUr_Fnc-A`2_+xo`p#4nI8+VqOM|F|QF7}^3$=!4BYfno%SU#!VbZKACzpTyQwnl7E<*hRGUZS}3=Cp

6<-RAvr-BB^sxycuIx=p^t`_NoCO5-H&j_bS5r1gJ!BOkcm``XffUh)GIrbYALF{f?Vz5@yKHx1+4{_Xm)1x-9uK@^Z~ESzW39jzri*f(z3Q`s)0m?> z64g%2b!Vo9vn~*8@Dbt_v2BaZ+T;7!6;{M7+Yvt9L*`3i<(;rNlWQ-_rY;kk5tW$P z3Z}$2X9jp5embe>lb>Z3m%@z0+&;PnTN^W5>3 zj)3S5_;P4X`IV^k%>tz+tX(20Mc27~bQ7WuY&rD8aNXRb#@`mo0&bgE&1u*LNu=#o zKJy(|dH*wH_jdfYPZ^53N(4eJp4tprr&Y+s&a8CBf!{;8Wp|ElYv zP+6yE>=rwd+u4<^tG+?Izu*7PZgUFV{0&;o?m2TxGwQi+@wA<%W1g-nx^9_0XQAYl z87=o6{fsBeIUq^pIc{s)wK?Tw+}3kQq96^6I3@SYlJ8xbJqIRra?f?!tE)aiiVWSE zTf@aBz4V7RqrCO2AMVymU8@c9US`y#txL1Lu1zI}8<-mSQPxrl(h1i7JLRKUWUx~0 z;=Ujwa9@OXOTv4uikg-WOD_MlWctr=QffoQzvg9ZHa8p#PBIEiPc~245x3ar8gEWQ z+Ts?MiDijB%MPuHjlF!nU*tN&%<9>N6({UgHzZ7dSiJjV_he62mj_>O{Cf7?Ut~JV zR-V!#t*a>?HAI&jNHHkfv5wPv!+Hfj7t4ZxU+4L4g>9m=n~M!jSFT<2#=GfQW$~=< z2^&mgx^|~VxIBCMT>XQZJO8?ZxtgnhueGo(G7ly&pBFQ_#*y;s8e&^)GBKPu)uoV?lU zL5-e_h2HlMNAB!9{GZ`!b+*cuQx>9P4ksUS^+m7oKi8xFzF1!`0hP4>zu|-+S5q z*H$x?VAS+DD%TQ^oU$QWd$PhVIj_?#>FKEj71#PY6t`~OoD?IFV7n%}HE)sEgLRB^ zz6A^0zMHOP`8spOdHu?Bp6&wT3u4XYltbGRx-+*%+voB>P%&Msxnk!;sfX%De@-4s z=vmZQe7a|u*vwB+=_fPit1hdVMkD4#p~TENZBY_xo@aJWxOLIoey4H6)q*q58r#yY z)E{Y6sSPvRkyPNzxVGcY+*QolP8I(dM5>n-FZ5R{{1<$_^MOrBSy~F8T&2A$Li^U?!_Dg^zqj^E*mw59NkId-4|={9 zVR>^V9J_vmVL^(wto3EXtrr;-h0i6<@Y#8`#L*ylqh(v7!HH&(n0ac~P0k2xUFfwn z^RCY8C}Ee=bMhE%PB4nZ1sr=mljZlUYe!h7c5S|^Ft>AU2dAGA>$^!zr4b)|CjZ>l zKjpIPS9wb%HL357Md?;&FHf5CYVQLPp3dauRe5H+6j*dZXS{4VJ?FX$L(3b##2K2) zTqG7fngh15{rAGqwNd4s*-ja2Vtg;1+Z@$$C$De<=N$v7ceR)DbUUB(Z2d8NYPQou zx2=iqy4&BcnJaZU|+lUE`(Dd5O86O)>zGIJ|#Pumws*(~iRZO%+)-TqfNFL#c0 z(;xXoQ&!!W=~>5r(Zcg=iIAi39>If#wSvd4A5f{8oX?lfI#J%WZ}o#Pj-+A>oxUTN z4U`<#ZcSQqjyL3_vFLL4wJLAQYNXa&5SsnNGRCT5CzZ%m%i zaOF*IbsB$>j9I8R%le~R*-w5=Y%~a4xQbCnIhA?AWpiJ%XT}Dx8tvjmZKozE{(gVL zCRirP^4=HsdcNJ;{+?&9sOSB3DBty=<+K{rd9OeHT-Ou$Xnu*?bN2<0)wR>#F5+ii z$9P(8SyIN%3A3*V9IJi&$Ljs4*-|Cs+3-q?Jm=iG+H1y-LV)o$s|-WqM43n{UA zrZm2ddu$N3X7=VEqIb8ytB^kYLzL^gY<2K+1FK%KI?z0u*qK|<-pc7?J+cp7Czl0Y zDt0+_qH1Eo`p8X_Hy3GJ=im8Y-mbKA@7lRvV%2RtE(TmQe~%~t-8y29Z%>4lfd}r1 zM4BftTGc#OITTgx)N$aWvUR`G=56zLKFH^D4SIcMqf%ZFJIh(m$hlK|e;ot zFzIgB`J>5`GAGJr9!jd|f~0^GOhp{CwpM-C^bPrl;;$pwu{w*gRNO7wm0Q5WZ+kKh z+!Kk|p0xR61$ zS!vKDN%J0!O?gLyk21Ih&HHuLO<~5VP5DZjApz5LYEu#a+Hf_~5Kh)mkZ-_=WCQOh zS;4FS8EU&k+)aB==p&*yVtdlcM@tI*<%`y2`O8f8vAX$FbnfQ8I|w|CZN6fn%roIjEYQZQUC`QFn`GMeMw%xTrz%fa#3y)T#>+6t;2E-J zDNn-d?(aN1`|!k&U7LSRERFif9<_=$p~0~=)snp`=Xv-VXeA^mxV~l+=W4er5cg$T z9Bz3wOI6EonWVtHn|-e3x!@GY5H>wXZOyJL%jdn~Gr89xG!-&X#MhDi@3)9XU$*pw zcu?0S^|5zP1DE=(V`T@oHD1cHIO!|unryujlm$V)^9s(KdfMvTD+^=q8T_6Hp)FVN z#BQsV@|>GJNhO8V+eIhb_WzMve2nA#tr@3J)VcU^sFfGJ-kpEv+1ZDuZNBd~<-C_c z`kTM+zvS*`?4b~c@OE66c# zD|p-r(qxtwxUR+HZrUrm8!`@)S+FW~_778Nc_GBSqX^R2QFh6WxqNHw-2QDHd$?A+ zMK(RAR%~n9CMZmOb0LY3?hTjfe9>PE6lpx;OJ|c3yJf zRiBcR44li`x@X)u*>cVyZjsmS-EJkRk-d^umlNYY9lNi>8|`px(T9$jS7wn@X19m< z&ugu;*!C~0^K9Eky90C5)jIj7^1hfR5^Ip+XHw|nJ?YBlX3p6$;lJV^8h=*f&vyPMdD}sH~R16iJ2HVp*yCv8ebals-iZyj&hAnH(T~(`C?VjtpMl^-reXWVqp%ubE)!DYW zyP9sAaB6PT>GvEb9!+_c+8MDsBdlw)pTN}15lQu`p|f9Zl!^Tmke2mUWY1ll%;S1` z-YzFCnTnV4?vygxc`Ep!jOvYhHM^$OO`cY?d3MvA7&VQI6A^x)Zzq=bTDrNHtMypa zdH!cOliyXHeW3fDy`r1yFO_@n{FjGPSa!Y3-*KkY!a0od*UI=tvSrn-lKWK8)$#0zfow?E z&7VBR9Cx1mUT%~pVlS{G{N^#`ziyB0bvw@-V!atQA}}C z9=Zx!nl42-f<(l?YIGI0Oq&qpXb@)M-E4g;xjwrt%ih{GMe^yj$8*)RO&)as2N=KP8Hvuw-#pJB8AGiY6|e;SqlXSMHt zhN+qRKdm+Y7rN{}!ztR!J~U(#(LA+4oOk8A755G-%w2k4t>E8V39dP+zL~{9)+ovDA)y?h9{j6-E?KyvHrtzm0Nh{WBzKK1(vs~}#o!!%m z-k~rzpbI9%o`#B5Ouh6e{^FC}kxv$FTDWzZ-oq3Bk1%KoFfuSPGBdF;GcqzVGB7YO zFfs@#3K%*Den>2AoVakK!o`md4;nZ?7br5?3tTpQy?Y+((w7IC+-Jnda@0RK`=E|T z>cWL9eLbp&KPcO%#;t!P(DqrO_G!)2>3z!YySnI*Tp-P?*IC7C ztlSW>?7hy(iNde!8Y2A`m#Tk!cQia$J=FCZQ;cNM(;wbDyf4?cp0@IC=EyoMCFt5C z^z`FmTYbN-;5(82cFf10-)X3e$=CA_43K>&&Yt+gLgKL3OyxvAMmt(tkp zgC`{8CHNUb)0kOQdAJ%x7*`hwJ1e~57rpzz_nT6zj?m#h^7oQ#KON1w)v8^1yhrb? z)da(Ubw`{x&2hK8+`*;yJ#NCi4Z%rWpJp7%$}YLpa8F$F(j0AnqgUT|eQE1CFS@Pi z!oI|$^1lg7UiZ4iPMtovO=h*FpNHjw>>Z4H=Gj-LP1*Zy#oncXITDY#cl#|dv;Ma+ zcWZDKb4A1tJ}Z$-pS#Y!w=P{|S}!wk^})yILPPdSEH~_5c`~Qk{%>#ZRI@E+L622b z8w1!v6RzA%U!Q$I+vbkbE3Tu7LX2}ZG`)HsE7@?UPJL!1_o@eWKd!{Qy5=7+TX>q% z_p5%juf$i!<~1^gTuHx?Gf_!RM$O!Es;2RmPS+sYFv&CD>J;@qDwfpRdw9)f+yAas z!03bmLk{CcCKiR(DfwGI{+g#cnp~3s2sb0~mI^xcpt>LkXUghZMQ?iLGx%D-}`AMhRe(t$o*Ah63 zA!;-4!3E_dHg*qw?D(~2|5_f#F3zM9kKamnT%SKac|=LAvB2&{^@JzuIX@lW;j(3-bA4HEWJUt-jQCz;}9Q(#+W(L?=kjFYbINTg>rj zXPuRR-Rt)YHOhZFCLN5+J?W>X+~;c;_L}ujQuGV0^-H#II6f=vhXTvv3s37Nmru&Q z8Y7vtP{EDcJk2D>Ir|>>PMVE#3`TF-q_53XM1&{uAGIH zyBp7}{@I7$F)%Q!5nuu5CUT<;1?qq9WhoH2~gVQsQ z9Nv2_^!*EuPFE%`lPvE8iQy!$+#ui3~sXJhiltO<-St{6T|>Uj~*{ZG>Q#I)cwUurWM zRz6UEAildWzM=X6LxTw8#)a$bvYo1xlViMDS=UKDIg@ltccPMNpRn}w370pOo;!H) zhtL|$md*7ZNv9p!8jaREJ`adBJQ$;WL#}L&M_?)ogx=Oqs?# z*=Sl$OLoy4tBG~2M;#W4iwbtU&Qg2Wli?XF@*wVfGXvKF1NrG^k5|5IS+(UMDB0zk zcjRB%x9mSd`0~`(a~CgSS!>ArVWm^>0v?u)O;g&{nzha|am+PqbbTQ+X(_`&p;Kut zk2;tfdk*ZF=BMpzv}I+~{Bqg<49i%Z8~6V_*1zPy+Yg+q>?ZkpqMSe8_b^C$t-G%2 z$?-M+j@2)o{5NO*o9DkQ|81+Vdse@3`rkAE8LI8?o&I+${+sb%p8pK>`oAUr9jjkC z{qM2)=f4=$FP#4({g>pwWB%Wa>NidXsVG+eZTW9o{brEF%zthE-;Dkzm#bvf_ z|F->S2%oCq|3}Vt#3Lm;_mFP8J z=Rah|v;5zlBN;iyN8BH)o5~cHudCYDdTgT1$53APek1Wy*B@z?Gh6g64B_;cyu$yn z`p3zWt?%D>Cb~`flDc`JyYs3Yd|^4(&nB-mlhRL(7oYil!9;}s9({T3ws5{dfDq@tC`KgxMa`e#)|LTl9%l)J-zcx&x}72SrJl?zsxD@ zd}T6OL%IL3LNd!wjrV_zo;~mHSm08)dEb-clFw$X6Hc2uE79nE@qF1YS3P-Jw{H#S z+FbowXvOJui>}_{o0(x|>~wn5iC3!^DK0v8V1?$x&Mo0{eL@*~e$KnKuPfY9^XKKJ z%hPAI=hdF_bW=F0>BbN%q1BgEJx8YQ-_vaKUnc%%us4r*DI&qV@4T0~688tYy_$S>R{p%&s@yv@ z4srKg@k#Gpyxx52kCUuN`d1_yOC~OHS^4SX&8kFEt;Kf?>rUMG#UsSPBQ0EPy*?)D zkcaQOH^)+M`&fKx{IToXfgaiK7Rl}>?!38cD&%IGGsX99LENz_hi~8NcO1@e*(fa$ zynUZ=`3J_JMFICFY*JvJ$eUzm$tD+v(8R1=D&t1H@a|f=&zTV#I9WUr8GZk?bq_J~xw!ZXUp=BhF{OjF)5 zQ7`q(Ck^wdANAtCEmmNHhJ@SWw+U#*aRg$WZFk4R2W zo+G-z;MznFbsb+h-Q3cJn+&$G%xkn|%390Pae05~p>#{NfNvYycQem9U67cuM|kbs z3fDE=0=ldVBp0;EBr)`z4s`gcce!8V*LUv@o>d8I3_OgNUG!A%2|j5KPTy+h{8ULn zFr?Gt?4p%LKN$8cJO9u?R#+|lN;Swxdm#DL z)UDTGe|V^_$&^HMp(F{hy-K@mqhzgC-rn_a>7?X`wSijCD<&S)d2wW|mJ;iWJ6>k? z56u+Z9$PA`_FF70@1cEfl1ws7h_&WU?w<{bZ$4+N56n;7=`7N1;-lVc=kJ`7vC4#@ zFMM8OPWJhn*}<2-oZfgY`CcM#%$x7e3f~r-TC_Cl=gjJfF$I^me?4bXBzQf&SI8pk zBg5w2t-HHttnKDhu{l1ijXRD1;Y7x(0-N7#n6qKVX~xsRdrDt_D!lV6>5&qbGAI>S zt!?9Mkd~IRULW&zuGEQ@8}HA2vsae?pox`%*L3Uq@eiNuT`zD}c~^6Er2L$JtP!VQ zitYPea3+0K{Fipk!+k#*V#N&E4btaLOZIC2c{07~#dVF;bFZ&2TqJvanZWWYqXkM9 zH;Z=)sJ)w_;&SX{+HaILa?JMUX5E@{qKdN46|yOC*0#c9{z#D*W-ZVeanwn{O)TC-_eTG6Jx*P@kq zcpWUYR<)ivop{W*Av9yrzochpJAVhIO5H82bL4m^#(KkctF!(;)|46+v-3P zoHa75Un(|FUF>*OJkYEpHPiM%!bhjWcc$&yZg+#%VOnzuqo9Cf*Bcv!8*KMds%!eTkXT{6XDcI2M0X5tQI7oL*tP z>Fc?d7k_&A&iBwa+h!Bvpl%mwlh~%&QMyp0x#`DLjx^p+Jw^g%OifZ9Nlbe+x7kSV zXVLk{tym>3P%80_eW`TSmE%8d*0QOr`&j?NHHV+u?&9L3i@$Mtc8S*T{hQ7xt)TR7 zJ^xB=E1BGWw&y3c=O3Ecp2HYlBtCD&eZiHkmF7Zq%d^%!42x$@l4xA2(<(IYx^A6! z;M1u=Nm{)9rYC2lXtlF>^sCLizWzf3*FAd<{aO10n2&XT?NRag?0!I%?@;rsPb{XU zI|L&w7Q15$|%WK)ld*e=F_X;__5C2w_=wD)B zD}3;5uYTK^Xm=%5r9Ab=!Id$uwg~2~`gE21QBU^e%ipUEAId#vaVb0=aQ)eqG{K1) zye*=!O{sg|&yI4w6!UIx@xy(y1m-(MI9+{Pk|xE$$`If+wcSnY@HDxHLF;A}>2JN` zUmLRVTb8xeQVVC^u8ads@kd@-PpO>{_MhQxyZW`?;j2zPchX^XOk64>_RgSrj#B3{ zqY3+V73wq{Us5A6xA%hFa=%5Q7U9P&`I@9y6i*lCiJYCH`boc4KI!S9h37Xk7lu62 z=`We`R(su?*PjgURF$wZ{ViJT@Qa1%o#9cXduyG_<92?$@2ly>VcaUaK2*SRxw+ik z_DzQc>IAg@BwwAm_{YEVr-eT%^iPR!ke}H6veaBZ{>Y(&F1No-h~`wdH|O8RzNJoY z%flVkvW7M2MwWDK%UQ-6abW%CJ&*2BZ8NXiz+q(Ep=#;7D6cQ;ibA~C+>+pIu@k)= zA9-UK*sea9U*I=OEB9CI`NK*xc=pba;KhyEPq$jek zuUMCre@*M)bp@fxoPP7w7j24@H^@4e?zU(i$Hs)t>i!_%O<&L5yvSy-Gi6O|%8TP( z`#P(G-)+pcy3_qyTWGP~F>?pon`sPpM5@G&E3_s_1vsu2dhH~<<@nL;TXBhT&aOYZ z4X-`B&7bheE+KP+YR!TbPUeSy3-#w8lRGtW5!0((iZhOGyP=b7v=;ko9j-G3)- zpStl&e%t!8_DheC{^82J%Eez6%~7%Vj?%a0w8eM3Efn)sJ92#xT<19b>UM|roTpWF z4M|sfUq{HBN6*gKnNU25({({oGlTSOua#eKac8vb_EY@JAck_ zUE6AVCGbN`bT|LR=Fd6LoxStUd^*&>!%6m;{}s`nD+@LqyKt%Muz$g~*G_st-z9RN z>J~)&Y0P|;* z%p~RX4I!^)=sr@$SLjTl=#_UL5qFQW)g2_sC7d<)yFZ zK3>GK;YgIxp{oa&Hm`hJ_B})1&D3M(zS*u*cdy*9rI2xQQ$y|Nk2(h`qTR1w5#_Mv z+b=3P)iU(6ylCTM-q=aM*ZAtL`?Pky-t>h!&TMw}izI_LMeR8=J#-`Qnm*B^#<|;9 zhe)X=NIq_;T9M`cpF!xelTQbGO=r}tuQRUAnO)Jd+`^*d=gy;jzn5Rxze3_;6yw|Z z9VPn|=4agdyfLmVd4<>Q?>g~LTmlb-j_S_78#mux@`d3(m9vat+xXV0cZ98Tm{k_H z^JBa(+XMl@aDBU=KmJ_nbJ)*BGFO;Xi+Eo*TfThDid}dAGj!(c&VD8N>HZZ_*OOUx zi7tm7nVCoELbt2;cnDPd`~Byrnq#`;lS)MeA*G0kpDlhx zyI9D#2~1~Or1M$sf_KY-$yKWN?!EVHthecTaCnX_=YBR{!wRNHGdN5-J-&F%^FI`+ zpSR<$*^lOVjBDenYhAlpmWdqpJ1i>KZWiZ1$Bq*L9Kc6Rnj#Z{@1Tr(UDrJsxDR0`xRWC)BqvR}vf)$&JQ^^atq zJ7Bj};?*(MX9u;TbCzCSk^4jP#H7zt{Y({Sy__u^r?o|4#kr@)t=FE(^P{nc)`9Z&m-Cc~!+=_iEy{soLAEc16pm6#kxZ=Yq>VnZ38gnTzzNC43JPW@A}l zq^aMsCx&N6-mcIKFQ#i6PB47c@UVID1&!S|0$=!W%$l<3&StqJ1&-Ipe2+^{)Qn75 z-Yd8Mae7L@%RdVp98L1N3%zaj@_L`&a44=>R+{TH=e={PbIQEtsrATN^vuuBV&q|a zyTq(_NdQ;GGFJ1lDZ1RotUILVsjyGIk@Gt|Tu0>TwYaaj*YyQdUntJ=V^6=hVyBU9 zTFP$}Mg-tIz6)xQAQHq@yAH-r8aqC0> z{(vL|otwBOc2=Ywdc8Vvc+DVDL-FH-JiGN?N)&>y~Dfq+}l~X zH*#XDP|%^{TPHd?8t!u{pg3KdU&iYxe_-tLy)$B`)SDw7wcxCh=+jcIZ4*`n8hadm@^R-hX`wUmI(7@{WTqZ}SsL-7J&*gqt2No1 zFFxUIZ^)8wyXulqsj}X1x;5|Z>Sw%67c>|hPrujm5!d zyZ9>PcTDWHW&1o^IP?B9wDBi#R%SR0MrF-x>wAM<9e)att%9X=*M z+ctMw(YKH7=KJRDIsI>zFVE=^F2P)n=NdfG(Dby^TOQJOR}$z zEcrU`eRhY~F13yyc09%`PZ#lo@6;0snO1c8`?sCHCaT+H|2r3FU$60Q+kT^HGf$<{ zfBeEZmwkTzCCvA4jiy7S#<$1=Gs}0Lu|8vZtndCLr3-6*7^ys~2;mGkqF?Z9j+)kK zqv-XnnyP&JZZ6!VSh?tccSDa*g9c|P?_8$^a&=+7U&@``2?DyrfFQdXf=vn2mC*`SFp0C)O zc4$i?v47vqj`z27*sIIZ>6`H;ZNeBbR4^6Yt|^H3}Q>D?0Ua6&bI#ycV`Qz#mPvz@6(ICGUMC7+6C?# z`eom;rd;^&d#>S|4~L#TDvot45{%53s!w^p!rH_9!3~d`y-T)LYwL;Z%d|I^eZEYX zf0y*m2-&G(kBfX3<~Z$Q=Db(+EcTc{TaX^x!?zh*CF;9X+i#{$WL(>GOP4L;eqIuP zqer{$#WR7+kD9nUPM>pq_m|A}nayvaD;$ERBzIWfmuxkw3N|qp40r4?SQEx%Iq%FY z$F8|<#}0Vh$=tb7U}CfkOZp%2?q3`2d*tQ{zgedEA*K4)hKtwMpPak;KZB~}yPBmg zu?_qVQx2KMz1O~{X<^dM`JShW|MZJFN+)y{X!3mR+0CubgAzUr^|D^nIX*MDvoy0tTLs(Jy<_Z2Jt-Q(jKJ z_*pS&t);7~-|6d%*t$!d9g5anlNz-kFCCZ@4jwtY<6IeEhr>4gJMR^PX6y*s zV&pxiFm|D+y2y6t_1eE|c)Z(X*p_`0u)Pu1cJJHHHCuY_bLg@#9r3B2@A6D{_cejF zH2YAk1qS8asi+Ntc^T}tzXwlVzETEMZTd2a7(&m)iiGemW1U08K% z?dq%T)lxC_`>fd=SmatRs^)9Fxggb=w{F``*(|~0F-T$dJi}wRx2%3M)jIm0bMv+jZJE;5Z}+HL z=2=eT*4r{A=~$zu9@n>p=Pvwbm|Xp~MzJHocR7#FcKf+{U!9WLWYd03zbmUbN&m^O zI>&zX+=qO8*AK~Dyy_SBpCP1TjsQafV*|&&*OsR~9ZzYwmD)J>;C}}7l=>4&JUSfl zr?leuX6=}7yLv zm0IUZl$qa=D{j7UT7194#A(W5j+-vBtA0AZao_Xe`kS}qK^ofQxYjA$pRsXg#fKio zr!OqSrziA$nmMs5Nur?ap7AdGH}Cv^Th0C6^Cthdb@sm_7p`xg&EHe7py0O1DUmsy zOnZ-o&K5Lxe$8TdyW-rL{|uW!I`V&O>0dbd!o_*Eu8gTQp^~&wcm?* zf_29`r_VR8eZ4&WnQO_B1#7qPruN+5^q=8f)7@KtjubE7KjYRf(SYJtI~3SWmsOrT z&3?&m&m*qC8zfg9RMS^aeyr70YF(s}9S!JXA~$PV};<;s{}2U~I81 zdHV28O`@u@&Uc;G9SidH%$kyfjrQ0`h#XNrb4)n-8nb5F(}Nx#HZ8te8NBK1s{;-{ za?7>W3S97(c(}f;z?Em`nIpU>!wx7csZ@F^tRMSA%5-V|0|1+e9ecxB;dhgdE+4Xyl zec!if(!Arx66C%glc_!5BX4_t&V9b`$8@|`Z?E{4eBZ!VJ(RaYA!jxc4gN6K8l4= zjJriHPdlSiX)R%Hz&9=PZq@UG7jsK2^e=Jz)Dq8OWfV=&7PEPIJJ)J0Ls@0rl+dOJ z?#ZW~Jr(=ZX@2C~M|WMrfLBbD4}ZIS#^R7^V4JBi|5Ht#_{lq-aL;(t5F&a}s?KBH zv@l+!n;s$iw+CHdSjNm$nzOmLVqVWc^5WJ+|d4=ufX+L;w+sR=G{)mSlaf49cp-ePCU@mM$zNLf}bs` zq^CJ|na{p??I!!yuqQRSA#!p_)2a{M&-x zM91Uldu)m?UYe@AyFEGL6PJhF)bz$py3TD{kqvhrrmKe8OlEqX|LXGdEosU{GPXB! z=IT#UoGZNR>lvFRQZ-K2tG|WLb7#2gdS~aJ>0c*sPI9sTX>@ekl*ntwZW)3in+`Q^ zb$#FPpF#0OtswKWt04VlZC(`B zzp1RrAmGYQJ*k%pd!}T4O_$y<^+M>C=}W>KdVXD5^==J&-nq21^{<;!r2F5#ILj29 zuk)w>mfoI+dH1G#QaEQ2yQc7kWBa_9ehmU^PV;O#mFOdWl>fl<^Dlam_Pv($n_3pP z*F&q`JD2I=6T_pWI?5MidSVXwK1*fT7<(gsh4|BVyR`)51omy_OAQUKYFfHy)oNWX ziJfPZwrV;x^;#zE5L@{#aC(bTsMCTiu{p^h#qoz5(_SrDw#K7M#J%Ri8=<4-QU(>_ z##&}B>ob=Yuro|FX5ck;SbIrGczJ}2E|1CK*Ed%lEAnA+V!rdik<*XyVPbBoVBY7j zH{oURp*I#N$#U+kEo+Ni*s;m{YTawDgNb2@UR(znIX1e#dm5ppxwdrTmQ8gso{NTPOBb=VBE)7jJpn<_WnKXU5iiP>3faxDy`>^US{8SZSmfy<0}&E zxNh#qyu7I3h~Gqy$*h((9@cB_9=9|2X!yd#An@DYEwM-%WCjXFT-08Z{R;F)z1y_-=0% zyS9OFgtMRb@db+7v&x#d<y^3W#l+Bpht3|;cbvM(w%5T&#c$(1-(5HMY+H5qQ1!`)&mD@^G09G|(YV0+G-991 zT5gA}uNI1gM>;Ryi;&&Qu&zw^S?o9QsbUu_4{X{J{xfQqcv;7~Q-_+kowp^29BN2d zy}CF+n^WcRrsmF^dna?lxVKI)JHe6X6PsHqH7?klPEb~ zuQ?_!@=Wx!rFU5mdKoV z^5oD2Ck;DC25<33{(dHfMP_P>uP#(fkeQowyz!F5z9(Gs*-n^Ai8HM>O-g`(^@|5shV_delY-hE*Q^S=EuftVyUmdeu zz?{b0*7S;V&C?ID7Z!5}=D!AZUG4R-j=fy^ zvE;yM7bh;k6TO`)GJ|I%mA_lLt^eV+yi{b_vo|+w z+*H=eJhIT+Xe-zh_34!Mi;_2gjW>RMWg+MNlEd`hSysncFUw`+uRciq8nH(1A}_Oi zpZy=Poo9A_+Tz~x_~RS5Gezv7<%_q^^jV-g@9M(Tud6RVN#$)Xan?AK(;jswY>^k+qIo+w%J_gGTzgFH2D|@tflX&5+gvLpK^geGX{S>Tiv-QGh&wX{)=Y3SS$;#Ziv2UWf zMdU(;7gA+%djn3No#7=cx%==hr#TzMIi`HFJG0=AQDA12#f;r=vif4R{hnX9I;Z-& z{*M3$`?fV%Ng2T@TF1WKt~j<^(Sv(k{LcAvf6w{Pz@h!Lq+*I zzl%kpwSN3V=0gd!Q)bJZ^8V^{blQ(yXDeMc-P;f?{y1nohsf;*+yA^=xJm#zO81{19yTQ7C*-MED99*5%Pb_x*Y`MEX+A=mS`~1x5bGuh|PHV53lWHdPUZRz8 zmt2UMs>S0ymrhm1&HcXKLC54q+S_-+Pxei?^6})w60v9=l?~rC-IoZwy#Bauot!M^ zk@vM#yXLq0sojzax$QqwaNFuz3Fk}Rh*j>q6_(?fxGVMYd`<7);j4BZiuSjDbH?V6 z>f>6I;_&+H~P7&3D4UX4eEHq6a%bjtB5irkqxtNh>2kMSOv7uhV#_USK) zRa0P2N^x=NOZ|J(cMZ$2ss~@}%1bz;zQ)fzm$>@J!{@G5>n?5Ko#!#fF+qi0b-mv9 znbB*eGBr0am`A!FF5EP6!8-M`$4g(f_gLBpHR!B*aC33ltm#qLuieYO^wjV2b%QIy zg4au1%I4WG5&8Ue)#k!w^L=M*yBK^Yub(rPW7n;-Z;}@-h~ez7pYxjk`1}*~I`urd zub;bS+_-Ff+0EwF)mV#lll!grzO9VX{c=OiuKZp4l#QYX*45l8`!}hytA!u66eU@aERrzy~pMT$2e}I^_tn-2Cob2Oocogr@ud&AMtXb{>=Noo?Crh ztsfS*ef1)R3rDiT*zQHId0Tq)$ZRo>xrxu?jnfZhPb=S-eSXI5Th`w@n@=5o9T;yf z`=4RC`<)+81Y&J2@B5e$(4)~Yqh`Bh+l{%?*N3q?nN8SWvf%1f?-;Qu`!y0bpRd~~ zzg!j1@jCQ?&<4q;dPg6`&pg+DQ^3pdPLHbA>CoIT$pecQo9tm?FuaDt4ZryUXmAiH0g+?c)4z>fOVa9)+w{ut4D^>e%H)~tC zG_$@pyDD8sVWwiQzWaZMYaFsKBP;mU7vyic<{Gol}8@H3be`L??*He6A( zx!s7{%2dBVN@LN))$i}Gpw1B^IhOSLm~gyjaOH!cAKq#MOG`ifh*#Z$-It3iE_Lte*R6q69hiJms@=J+|*U~mTK{B;Wn~*vO@YUpOf|q4tJhb$urVs zciQZB6qmbAT9^LyWujx=3Xjy;=M}iaIrY~ZsMAD+;@ZbwnOi4$Fg71N98W? zJxExzq2N$y+ANM<2JtwfMUMkMcW}@CQSjA!1>=RTcsr5cgF;4k@BS@kuxZPAvS|7A z<_AY5&*rv={k3RO{L{V3x`#8Y?9O2Wjzc$|=2fp3{QHkHId#%9lfs0p+u6Pb{QJdK zq{lZ?LPF1=qbo>!U(nvqJkA@-W~hESX7XE~|K04lZvrYfR(0>%aKl6UG;3GKPeoPR z0KV{v{dQ+ryvrM=*nU${*p+o;ZQG2>`~*%GWij2G#wlFP^NRx}yy}}}?73ydU6+Ts zC%ASeKMGQK__RSeX?}RL;T3Mhg|}mpE=U^g3(BcTd$Nq%V0%P^V^7F!C%dVZ4q|KS z)|}?vrPpxkh>cQi-R6$kq)SqtgA-r4KAOE&{CD?q1M`jNj~rw@Fn?;(zYTc@-IYpB zR>n2`e4Mk(T7KU|1Mj7E;VSz%82|Du@#*Q`D^_?{Et<>GMt-9F5vx=U^T_aH(diCG zv#+S7+zda{d)M&zIqqM##2NY&nt161&gBvo2h$JkefrVI_iohI&kWlx zzhc}f80jNn(rURh)?u5^h3%=#mXHvxBp$e{_nYK|1%^+2mGinT(-~t zUEv@5JNv)W}_=o?UC^!nF7D+bcynOT*IF_Er0t$pS7%~+WmL?AI_e=KUVCo(&gCAd9xy=UYfpK zcX4z4@yzS1eP-$2I=ierZPLpXH@8QYD0)V6g(gc0@SkCK|9cRp&3;Ay ztH-}){5!_~#qe)WeX;zVi`DXP>u!0C%?oUTqz-N=*N=MeMfqX1=BV6HB9*@cIH6k9}b>nA-u9oEJ|LD%uyxw z46_}QKOL<*V{nC|+m1`YxWQ7Hvhc8#{Ssfe||1+@3^+*0D{&RoL{Bf-+ zf2jO-C7-3*4Kvt2x-I#Cguz0PfsuugiG`J!nT45wk(H5w8N_2?6JQqoHd z3@esqM6565T3S7Ccf2I0VW7sBM|WC}o=?)N7OcO&(fG#9H?ANKZ}E-nc}HTtDLvmP zZrir=jp>mK^Z)$jExyqW5#4YdBQnCUM={^?pbewwkQ|hNm}XkeBIQZjjP(vPAv?`Sn~3N@p6ZA z^O>z`cwSyGUVh-*Trd+RS|aoELor0Oga@P+Nx=b#wx}19mmj42H_o+VD|sP#c>#ne zw8^ORlx?7B%2na1CD*U}$SgcM!O|hIeano)Q_gdr4=Gr+I&@wB&D7OtGr>2?PuuUh}MZ3@TBGm}f1)||<}RP|Xc!7X{uEX&qtY3Ov$ewFecHyw{GY7`Kg`m1Tx)fF2nmql5g);zKOdvUDk z11G;J@$a6k+H;MSx3%eWs@wGL*b^sDW#_~nDGPO!uzjz`neodS+Ji?bb7 zzS#LI++O{|y&Q>_NpDS;_OBG#=lj)CW4V>2>W5z`R@eQ@=3G8@B}k6pl@xR4OWnSh zvW97=u5LIN9p+Fw=i-s{mnnbtUbyrkVa=g{j85?nK@sWBHJqtEKj+xZWZQe$ds)fC zuUewczap(oMH#oenRZM^Puh1bPf=ueK(@Bp^<_&gO)|Odwyg1R<>p6x-u2$Irr{`n>+vB zsFt+<6Igz!_Mckz?3+^zj$}-`b?n+JrK6J%?W{BK=-M@D^U*bXtqR4$mn|$7ZneLu zZyL9G+sYj}9^cI;z4vRIx_sZ2j|E$6&Ep#1K3e~%dscZ=v`WX;muuFk28F52*dBga z-|Nz8m(V!Rz@Uk@+Dv;|r>h?ENxq}5d~?~3zC}sy+Y++hwzT(T&E$Euc4xE1gsw+v zGaJ=>zDZPVKmC&b<5TyeY}wH-O9Ub!wwZksJOA;_J>kbaD}D;BX*NA|@n%FHx52T> z6H991C!E>XEOyFxvDdY#uvx2Seo87>{(a^gw|`DPdw;)#__j;-O0@KkMempF{^PT? zIocxQG;eKWAd`Y@{Nczr*ITVQs|9Xd68zk?+3w>abuRl&asEpeOjkTMeO2&)!g8w9>i&Q7nO|w33hYt|i?#Z?g2l zuW5X9b6TeVn(995+CSAV8)xb7ef5if!CA=_?aQCrKEL*B9^b+QAyuDcP8)cdvUw9@ ztD`0^PUu)z-n(kg^;v&ybl{570cH~qZ5_w)Hn zKcBz!^ZCmfn1nud2?3BMbUCMC1X!=h8G?$32XwWL4m z^7E~kpKopX`8KQO+fuu5H^H~=vj46uKM$3-^0Rb#w$gQxLzfF0t&TF)X9qvuy7Kd_ zD?i_Q)m+_buv$vASIRO?l1b{VxPV6A^tY>Oz8$smx+I#O|K;t6E5?##pT?1|Iux!i7fx@~24%d%{rY}2Fu8|IW}?C z^(dDw!riha$J5zvUEaDZBfc+x)#82JFR#Qd___G^Zo9dae?m0xcD!G8Yx9No8*jGjOAxb5p>Q_aXzHEolR=lThdFoW3PaqHPxt)ySAy=!j; zoA%!hSJ!TOHsj5M+fm<^oVBi#N)+t>ekP`)>=ZN4%={HcgG#vnewNpae-_Jla_hNO zx-r+?^7oye{IxRuXie$GzjtSAee$c=_2k8>neR70z5Dsygp9ZRA3V1$*=!o4Qc-el z!EB=fd1F$ee2#mIct2gaog6nnS1T?*lu0lvFwWM zExTXa_lVcZ-@Uncf9mnN*ZbOksN9aJVSF~>J@c*859W)1zr}wHAhRbNh(mykRvcSClI{G*B6w!F2Bj@C+6jP?YYfW8=MBN zZ&mv$yF)rV?$PXk@{HfBrQYxQ>2o&kisxY$Pz-#IdA76qZc19u=A1~q9^ds}9;}U8 zD$?<^D^zfM^GDWg8{WOY-#T~0PjE)kdU9&UNu8@pq_g6#FAJ#w7kybu-dU0YQY zyVY!=PDzPD+RQfxKBqH^)>-Q~|_$9<2K=nGq=m3cQ{ z(nO^*x1ROc9@uGBB7A-BL9+{W3fr#!z5Dya=56wNbt1i6CbGSnxOG-nV1G*3WN6;D z&agkK{BkCT7(>9k8h3{&9`7#8&XLZJyE6Oow+p#ztGjX??=F77N+->Ii^9=sIz=v% zPZe3*vfsTu^|#XFr6=6=)uR06zfD=MeaHQgWnks9c~`a01uk2(Xt~MR*qECIVNSX1 zkGON=@7~`JDPw}nE>G&=R@_iEYm<*>|Aar#LJ}I?DU)5-gtwW_Ip&dkIX9|fIx9GR zZF!ry*BzF=uGyWQwR`tuPy*ZZ_tuUl{~0E|k6!-tR_js5EpvBFp5?W6CaYjd#_LH_ zPqXZ_UEz_M^_Cmm1)`Hu&!kMty5750#r>)KF}6bIt?jw&KbGXK`R(WaXYG;O;pIh; zfG^u}Z~nIC-0VA(LE&B-efQ+P{&#O~-rw~2V&LiXlin`;CunwkzJ%oB-J8v3e)QfW z-=Vv;y}a&^&f6kz*7vl@TPbB6=xx)@v|(o8$HqBL zzi(PzuJ+4%_x2(;#{J4KvpxUb+O|xDf%oGVtIsXITTk41kzAE=rRcV4uv$&hdyo$k z@7~|f^*iwsxWw_c&|xur74_3F-R1~1OCd7vsTW74>m>hH{-57%zUJfj;Nl*8Jh$$7@}WPQ$cd!ERJsfJMz zuUmzAj!Hkeexzm9f|L72W*_>|bUUKF{QaSOH;2C9ESEWJ(<-nm301XAN zHqOPLp18M4X%+EVo|O(gw{_<0=OQncSzDN0OOd*^*gS3BJdQUE{ws2q)Xs6*bNW$T z(TS32!gUY(7pd;GoViPM{=ah<);#^sApD{-*DqV)`sm>boOQ zShtj=yJ3r4v&q#@QU%wtGQ@aXies+&Wo}zA>xSvlqPL**2?{&zYEan8=c+A^YFuop z@>3{X0Me#{mes%bRSajWH(Pw=rD?aAn%h<5#3LyIp1U?qKg+c)T;KFpR>_&&dj6$# z^VY9(2|KfU-FiRQiEAHkp2b>vX8xDE#oEfRE8hQ>6Mb6ppJBrqlfSiHTKjJMoL>9v zTP?>9O?T#VRoJ|(^*$sJaS$8x91h(#$?;$d`0JA zW%aU$9+MM{_`|kj2e?+joRDy4qHLeU)Kja^mi=e=bKt;q<&WIn(c;oe;*LC5y0%S* z&HUtD;rTaB&du}i@eDF$Tb!C5!eH1@WYKb`)VPPm;+{G0J@I$KLY4x}mv8k>^L3aX zocZi@(y87rA=jMe9oL+@gyTzSU#xD9{yr(59j{N!4~G>dmpn{XNNox0cenHXGCuNQsR$6N%t3)t~Wr^>~J$>u?%c|wQf+-7Tl`klh zlv7_QxqMyFE%QHLbFEzLSMmM%nWVMov%{_POhpN%xeJ01{bwk0p0Z-HjZ{+pP1&Ua zC(ntjveu}%~Hp9lGa6zNRkC`nN-#WZCwfoh%WhryrGd06! zpZLCN=j|RB<2}l~OZ8P3X}npj8sZUgX zkWJes&62%=;j@aK%$cM&bC!0Pr|FU@oh-}F-+r-rgKEmQxsw>UE40p7$cwDwG+VQJ zdZF@V*U3xsm6TS>sdwHJD!RL_SX5N$m9$0IJZWpC$eCO|5A$;-PM7C%yOpQ;B$D^= z)kD&+;+`*m52-A#$uE-4xy@do?C|jDoqc{La^gNW_xHmL0`i7FSw%7RbLdb=zsF+c&e( zRy63`I@iEIi}M#f|M@Xi^wv2pw&;}YS(2~AGdLrTx~rSm1bF(nO<%M^?Q^*A!xHt+ z5tA$O%f)9m8?7s?-ea`=VS0{T%pXnZOu^o%I$>GaoY)L*BlR8_IZZEyccPX4Z@a+Rsvu@`(@eEQXFmQ@^jxMfY)zU!_*5gSkSWzTq; z^_<%pR1SIE_P%wpQ+ML*6$}A4MdN0Memn@OJ8rd1Y|dczntznz@UscaU!Q*Y=i+Ly zV+UV+@AjK}XV1aR7i;`~wr!KJ-yR^lDy((3noE{z)3Jr~mMYKN@7CV`$kJ!$+L}ia zK3RXKPr4X3>rk@b&P>IP2~*bHvz)?~D0CuIX~)`MCNHBORmeo0Nc;CV^Tqjphchp4 ze`j`V)B38t6HMR!;q&$*r<*O6(_uOBa)aiYBhRuf@_yAXz6@zxtg&`% z@jMd7BUPc}m&zjX@jrtkkHK>DY3~Fkzlrr&`AslCZK9vv=Rkah(_RmsY=vwu;;Ml?CYH|*XdOc!YS5(caWVmZzlk~ZzYj?+b?my0=PcAOk z;ad`aqhU`={2e#R;2Edc4F5CixmBU>mo%GaLg&-{7Lm7AQqLcnxNV}q@sOiw%ePO~ z^Io(L+T1c*q@cw5O!F&8^f%6R%Vy6~p7(scxj~-t#2$?f_0^oI!AdL7I8T#t3-5C_ z%lZ@&qg1(JPUn{^$2=ymh!_eu6<<}IyR2{~f5^?F@q1MtR5TQG*nvg({%Z= z<)$u9{~5f5*46L5&8Ky3P3yXq6VqA(I#jJMHWg{dtXNY$>sf@`?2AnEp^8UnQx5d&5Fqg+H+H9 z=F4ZlW_%OwPF6jx^U+sm`_*ad0u(nze_v*udad)Ph1{%2=e@SxZ_hk5oMl$6Vk*;l z(o${dgpI3uR&MJ}zIOV^ohvJ@t}ySfs`$6+fKsfh^5xK(_npE#BeYEwnldN;o3`)3 z)lD`Z`l9TzJSS&s*_`y#sjm8QK_Jfgp{GpQ$#T2u0$BUy=~;>8u2pLTknj&hDPGynVOM0{M}3!WwskPnib76ox5jtXwuyC6BcC0?(3S- z+VbM(8pWz?eoM29UJO6iS7}+lo2ezC&VSnSOKfG`?at1D}_O^&#vppkZ@QUC1Z{|uM>HlJQGG3jjJ;mymY zhV(@8&sCXtTlP1^*^=KbISQ-Y`IZ+(>76Y(UR9H$ zXSx_TO1!zGv%BNN!*gM-d*^diw`}CiOFobydfz9aid*c*R@L;Nh9_ws->A7heR6T1 zW44>8nA8H(%dwJ=s{}j#H}i<&5@RpUnm`BU z^V1r0ez>ihu<>j*pN#oNr5RVxt&=$ZC3AJi=C+Q%!Co8uHo0!NvA)QZ;aH1qlK6jy zU2$59GEG9edE6!Xl>g0D_SwYy+ILlMEsLqJU5jp2*osf9WF&R9POV#KJM&M2O_0Lr zu$09fntO`!-iJMG&huTjme=D6d!AU?;in*%&J#YFCYradN9pR0K5)X;__pDlR%Ket z%QdULt|soyjh&d)|Ge{)*H69)t%iJGEA3XzR=VotY$&FCL}%ABQ^!Br8+4u>_j7JE zy`ZwmS>-kBRGx+Pmz`arlZ%7r>ALs}-HZ@B=iTSOd>X^ko-Y-PPpMRDs_LxaOjk`2 zy18}5#VVo8K0In?>+ZltZImS(S7%DD((~7N*Vze6w>MLD3a2U8zh1ak)v-qJDc>Xg zd#`;w)`x1?zgTkV*sK+qE-GFfd+wH(MHkoK@Rm&NnK-{|=OIu_%}%Cw%hrpJWjB2K zxz5}8^M8iQKycyR_@9Ak)w9q*8>dzK+OzKYZn|usQ@LL)lUw4{Dy74d*tNv%@3G0u z*hbax;5}x*`>AIkIn!7(Rtf`y1!jH*Y3L21v}Yz0k^e< zbHGjNz_*RRD)owDp6onX7+5!dUs24H9aoEEx~BeGcSx$jQ#|~-^qatQt?IvJg%tPi zoS4-9+tW%UFXLy_t}QYb%B)^;~P!-_MhttHMkw2C?BK=!6b|YbIx}nx{=l zcZM{L+q3x|aHf>U+xUS0l`Qo}N_eaikkr$fIWL5Ylw>{0?G^yoU-^V?I zk9X88f+#S5zPL=sU8y)ais4N1((r{|`ad4`xvT$Ksc|;Phb3aweI9$a{|w(>+@06^ z^Tpk6Nr`#tcP^OdeBB^!U$bn1G~47gleG3)E{EA?Ucb{IappXi-BF#QY9GY&#L6~< zliJkXI>}~S_x`Rq-#OJ@Lh>lzHQCqM(SC-TY&?9r`r=o`=vxLH+`W#q$H>pu+V6BE z-{-}RI&OE|7OAWJN_7t3eV+AdMozL&w#nb%+-H^gJ#2nb2EJLQg2FE+N=%G-DOf6- z5@1lgC?Ld*CG4hWuRwp5$Mi1~oGq@lOqT4)a+#VRe_3sxRXVqIO3#iTMP>_JqXW{` zH!z3q@>&*BQve$3EKmA0Th^@lVsE%cccyMf!jHVdOFD|5YSu0-`^|GPzAY^H+k;%A zM3!$2a?(8??xe4r%^P^>+vE>#ie0`7rAjSdkajs`N*rVR;lg>+^S?@b3b;2fO#I_+ z{ogV^>)%ZHUbC~v@1ygFnCDH;nTy0KT8-C+Jvl8=G)J&K?0eJOL+7CVvlfOX=9Fct z_LfYyV)z7Vhc3O%yeY|{U&pDk%El%t!)wlzPe~!JuIg);n6KD$Ri`bE-ZFn{ZCl3t z!`fQb1!el>e13ZxV?spw%dVuZp7p}x$*u518_x>f@$B?vwa@G4XO0w5aoYH;aHjU9 zl$8&=Pp+0R;;Ii-=KgVa`rj_O@G^_)YcrfomejB6owv$NGtshgwY12dKOBmS7A;#F zSI&C7b?-ViaAPj|x_fbg-RGV2g~3If?HcRuXkTH0%J(a0My|-yl|P|At0jb?HTF@J zRG2_Bx9Ahkpf%!CtTmb&eHB+Osux+a;_bnke;-7ywVsszewysY-6wy9Sq5fBHLq-c zzjDW{XIbl2)^6NnZ1eN1<+GjVy2S*NK8K~OT+r6B#L;)-pIsN6`#G3o%TwBSYCVvA z^{FK4%!{ovFRm`UYItN{ishQgDMu5QnK`Risz66ngUlAZTW~f$hUs9*5@=a>;GN=x z^KN`>vv}N8x*gqYrYVash@>gAp65;J_{tL)rWw;V*X)>X#-5YnRdc?r4d8b9Cm9)d zyLqGaj?GcuRc@tcrG45b+IMxzr?>GZOurVqmVdyoK>JSX^nPWRtHpm)I{!0d1Zq2r zo^suK#o*PfwWWKHbQdi?9eY#p{govGOA>5j+f;V*v>U1_Pb|8)_Fwnsa3$59W)I_Y zAj6?fTNs;eR<2qf=Cliv6c*k(yoW<)bw(qf4jbdWHI*zdp2bQW96R4eLv# zbCYybH7(*J}$jFS1&xVjf?A#oh!H7%C@Ye>WckQ|Rx^w-pv+nk$7?@7|CZyfh#%g*&kNNECz&Mc7ypparFTUQq zq`ko~$;sWKsyoCg+~UGFaCa3nvLE#QMA!k{=f!qc!Na=OWRI`bbvcq$z3;Z|{oHx) zx1BG)oxc0+=DXi+zWbdsdF7g=tq0Xi?@XV?9vaozVe-9p9g@oEl%31wq#AzOb;>tV zWa3|_{#(!C`oGt1tKN58CvREgR%vC$(&F^04Ktv(do!r%Ss$z=j{npw|ZZS2M z(gEKc($=l&d(k)J2xQtJo9}G)yeHWua+4zts_d4V9NQTfJy&hFm2X)|*PEB3H!o># z)XFZ*(k{%(-n`_x{@t);+pT=dRDJVGX1($1dJ{G4%}djpmq2n!A>rvB>yE5kcXZ{t zbCYAcgQL3xqvtAtje@HRd+rH}uDMFPP47*KEOe`SzVUF;)}QdviC&+=BRSV!t$sFF zmLX=-;>XRP(TQ!1|J-xv z*MyTCibp-)DplU~I5TB!?VRh!iws<+q#eu*O!XJIa9S<;l8|Im?aYY&+@6(;%2K8? z%`^p7ivKe_n6&hy^-MXI=QI3+mh3#9-F4#gofqqjr^P%;pWnLSgNxJRlnDMqu34Y6 z^zJzQbBWyn>kdxdmoahc(>?FfZ^69Q@Xw9+O`5{&r#*&ZHcHy7_emD5J>qfn)6ZM~ zUJ9mYTut#dI;Z8%9A&^L7=AQU(Xw{`MaSo7v<3LX=VWZMyS8%Le});AMfZ1eH57So z{48`*ZnIQRn`BPa$`kq~$DXWs%zLb+@0v|T%CD5FlhyK3S91c+1#XSZKYu=5|Mj^^ zP3E_!`OmuXCd|kt-M!7Vv`BxkUT~fIlf_nrLIyfVF1SkztW?vM2w}8&sQm4b_$Q^e zht6HA0nGw0c(>s6y=S$nK$CT{8>};SDzsNIdq_%zDc|f{Gik|ZAq^EnnVYhD&X!vO z9&OXuBkd`DM%=Jz?F_jr<0F=fw`5reJKg7&>gsg~bgE9XIQ8r3SC7DR>m&7@3`*N7 z1D{WqztS2p^+K$u%(~eO%d7+^Ew^v`? zlI6mg2hB3}CTJ;6v$=m|m(O3H#pkb_vO%M~7oWef%jPeLIt5a4^iir@-Lk;vuTtgo zg*85ZHJWT6^wRjNQNMjKh}SRNIc4UrneN?3V5-&XmIXfgh%8_EYr6aVl_eE>yqAD1 z^ivCbw)vXFUWUg`CoY~fILlBnm3hn6>>1Uq&v||ae0NB>)e)u}Ht*G(twJ{3o~vy* zPJDX3TtlXFj`zZ()@@Bk{xir=|NG~2TS$+i-GyzzQ{Khwy4sa5lQE^_$c~8*JWLH% z&+6d0av`iVdqTE%(v{G&#wC37nkO3^*mn7;kVmtYl5(%qzL;&R539+{$m%Ev^PO}= zVaX92SGPH)!F@**&dXekTWqi@eP+iu8P>k9l9B#N-xkjbn4lkKyJef#oX@A~`ww<| zCpQW5xJb+TCmmZohe6EwLXv98gN<=NKK5(=i|~!K;0Y2l@ie*`B`JDYY4fp+3#s0& z0VkTZl$2)K)||`@Z}ZVK6csqIX13-$gOgnqeQq+Q#Sp9GpUwz1K6ONykLg%d&)Vf1 zlmpKybcpZl_lg$vt9)uJ|8%{}#$%oDt9aKf+n601qNviO%pUgOTING;Q`HTg+$XxW zY_nXp;1}0566X~z$v!ezvy8Rln8ecw{~09RQ%!u_43;W(tXI}m-zc~?NAq+2x+}g8GcUdQ zQ)d&FCHhUb0Dp7e?%330MrhnRSeq=s@Xx_ctM zVdJA%7gvXQs#lh{7%0ac&)T5gTBsT5!|{FM?FWJnqED6g91?vY`9S8xG_S5oq9pj6MR!;yE-@F*(!~$54Lg^JUoAG zl-8|YFOX<*@z>V2%$d2uRssTX{F-Kf}c())p|s9Q=w|W@azfS{GOww|u3j>Pp3Si|AYDL1SW2 zqqc%3KHRsq3!AWia!7jmykz1QF&CAVoK4Dt@7#Q4+_Z!Aez|Wk{$m?>i~EaXij!zi zw2ju2=cW_cQaxtIe`fBU z#QYPSEe|FhuK9KO+Rn9RzudR*Lrf39KJUaTfsK9D6FU0+cV>cy13)ui?f)509Nc=nY~pmM53a5UDp$CC znm*rt=af}XK9^szom2nt)w~Pw3+(c{f5q>5c;%ky=M@tD+&i1!rk#m-#Q(`S7ghmW zau?K^aqRw_woO{L;Aw<~w+?S>_~*g9u6j$)75R+LsoPlBPG}eGc|7$o4t`{;*Vy8R0r@3fX^3etVdn4H^3{IU+h^ z;;dQAd@TJZy{kkHsOz$~+_L05W(IAL)}Qm!i`Dvy*txZZk}u35Ug*0Jzi95&vlHIO z*{kw({caE6=WiiZ{K1;}`{p#gLgt@>+u$ShRV$5_8U>dYx}BQ76*?DxO?C_WCx_hP zyD4c4e{@dydNN&CdCiP}^Sm3Tn`(x*8Jb_RT{!>YtG)}dJ99n5ZU^u1I`QJC2G7nl zUMn?NE_eQAPpF=4SYLWh;Xi}2*&W!bBc;H73v$yIO-jBo5!5Yva83iX;J|(obN0N~ z%BK8Be46=OJ}+=cn7|rnms&FGyK+kIX_Nj8`5#q_w}`)JW%K!@&CsNL`l(rIN!O#F z?w98`{*d3g*bEve*Om1^LqK;Xx1Gq5Te5z#Sn(OiXi0xI-!0~!3dYl7zDG*de-u_Y zGnwT@#4}62Q0-^_(=A`Tw0_BV?)-6r3J$rW`6b zj@Q%5W#$n?Q=oOS*ftT~Oz+jYKT@~`$vbG|i&5?g=23AhwimR1;%Jt=qjvc)kI9Km{X88Gg737azMrh;vegx-tZ|!E(HGvjzF4N;)0p36 zUgBr}tM#7W+>58a3ys|o2`VA^+-{}S%(y&5@Rdb-Z$kJ)BT&nB>8->2CO7jL+op6^ zb=|bnEPA35mu_JnHsQ_V{|x*gWleueCrqkYBK%iJbHX)cQxUHdEFK@t9_D95X3usk zpO|FcAGRTHMb%z#*|PZ7;k_~?lath}gW}dsteNn)${AD|2H)b|Zof)(Qs5=?1K)S7 z3%AY_`?mQhXx?R##O#2hX-hXx)=NI{XE|ie+dE?$k0G$7!-24*88k1GSd*vQ4Ec5WK?_Hrk|v#t?Dw7<&>m+aA3@y z$r;}+KjLK**}wJN{_MGRm#zM;t8Th&doXw2gKg&vqMq;2-F!!E=R5A0@2u-ef5*K3 zw)6U1UO+ThL#yV#B$W zvr1G<7fhYC%c48%k);va=9M!_dEHi{|f`( zgfGciSw50-iT$3ceMMV?Cw)4rpg8Mjgv-pZZ1u2R#~2jjdz7!{^z)^po6X8CJ+zm- zHk&PCnuY45eXBUmc<-(-Q%+X)HLdkgoU?t}hBpz%j(<3EdFmNsQ8D5TYWUS5Xt{uC1ck$i{uqHD-9d&>RehjzlSMk;`_#Up{A{==O*L_q-rf=n-w^9 z)wHXumQoKq`hJ}MVE&Z;zB)ofbZ%yaByua=IjC*#E> zu^}FdXZ>iuKVN^JMAaNFf4RTf>F)XiLs_L-Z8KHGhq zF?HqwzBvoKm#+S~>)1(Ak>|Uvrq5WVu}Cn*{*XcDV@pAwv-Z5g|3c1r=z5%K|9Ern zQWxz8jnr4Y>e_x&Wv7^@tgtF>Ha=bKqg(EEde-_d`GrpkEw%R?*RGKcic)o1YPD{g zl+tSdyv#D5S=q0b?5LKRBHp_!sMyfv(axW_E22|bCr)Nx%W`qC*r%`49wyx85C4(8 zAT2h_{1zv}DQ}aNQM(a?|TynxWs8Gwdq4^YU`> za^G`v&8pu+y}(0IrH+e@p9jD^Np<_%(Lg_n_Gd@8ZSQsQc!XRuB`^DZN4#7 zf$zDw#!8pZO)6vMIe6u|kw$6jtfO5MBbk(!%d$0aGnh!5J)OwnZ@GF)>n67Q{pnC6bAx?d{L^+n?{d-@UG5 z#fn~ciHFp}F#y!)H{AW0_)?eh{vaih_XV3Z@ zxLioeQu3P0^N7jQs?vCSj-N8$xZ3!}*PU;w?tHV&tCreq+aEpe_}cTy*Pd^DU5t>B z+m0cTb>^FCS~Wr?JhIqyP*Kcv|K-0IGjFM7PStEJ-L-J}TUGy= zou~fH_|NeDPSz`BX2-gqsXbRK4!UKXJ+7~@;=($UWAdv|phevZ2&{SdYvJeiSMBHI z;X-vQK!U#(LL|UatD1eXdy-Cj?nr+can7^7dHR{QO=pB|&v!C|IWGE@`^fxAXSHAX#p-Zq^RDpO?1w7fwLhNmW3tPx z-S^`Em_OogEScqDCw{ubyDw+i+$TDl`8b=@XB=5`us!4aGuJ(G4h&AJ|u z@xJrql(nhrwp_kjx@C*X+e^KfUYEW|K4UD>UN`ZrP(jkQ$VM*am?+mRo2I(BEh={o z^PG6!ZDPKLw|=I^b*)Rfs?#@mUE6VOXOQh8KOR4yP1lxY&dPk(UFlTmBDhX9lT+ui zW?zfwTI)rZB6nwrtP{B&@vRdiw=!tQ@hzSQyPeXKc7(^8e2@E+XL2R~-p?SB8Chyc zuG|TWEPb+-C2LMti_JP~`)tPKt(r@YpEfd9zNJ3pKf~s0x{@a+WOtPt>Z;BSJX2*o zd(FYrsR3GB6yD7Kl`bq|oOUS4yyu= zGJ}>GGq)G>?48+?k>O?dSN>P;%u4q^aV3gg_%O^>V z<7mKvIp?3bJhDHl9sJDkmU&6H;f!f10V^)Nz2Mb5WveOg*L&UFtgoaQx$MnV zZ69%_B~fRsR~A?|Ge4u(mgSMHn=^w}MkysJ&s?Xo+jz29 z*y^UoO-t(C|1D073;fR@c%rvI$c(Qz_CEvvzx>>0)bMU3O+q$L%k*Z*tX-&FU5X=^VN{Fp++o0Do9OAHfDHqBhKLdw`9Ls;}(l$}qy&YI;VC*xc!B3t%bh4vQJ zztT~gQEf44e^yY>?j;eu z^uv6|YSl?OR^Uu5XyNRdZI*j1B&gscem2 z4UV>fMke#W?bE|ct1N%`Hu&C}HDv{xFi)DJeO7CRaL<06x|Hi_ z)Jui=hw1`vE!lK&rBBwb8)|R=o!>ms>(b0?+P^KIF7}C<(IZ^Lb9JT9ORuD(nL3xw zT(;skXj<*u!OD2=?#yX#|NXnaP};aCZ`!64T`#P5cbr>l{rb}OoYNL|Ks#HQKZKf!8KA!=VkmBpXZS@!5_x8{z$Nl2PeMM0XcAuEFk{0l@gEpB6~x%elT zX)tA9JAcSK3yi)1(**}YpznZ-!vhwUTojuQ9i^T_D<2rTL<+#@Cb2|H$ zuZgVOKLslHXVJOItnOQlmt4EVcV)%HkP~;^z&aq-?m9aCs(HlZ%~97yPfaaCKS$I6CtO$>%fV3ArIChS*m$nj}$7~t+VB*@!BGfO`oBb?_0U+==3>v zlTP()TG>5AbJ^SdPG)IO?#|fye)UU_nVuoMjF9liyRNUf_tN5~yq={|!%w zMMRCgv!rXmgIU>P!G+2`kDu30Xf+HuBe7xnk)soYMb^eBhum4e!Nw;-PEF@zwdu*V z*?tqZzKH#O*+g6_ZLM9PdB%*)1|`u8;yGtec1rH{oN5xLv+R+hBYS7`nyZW}W_cZ* za@b(zESKH+7kX#zG#8oVF8U-p@u*k%wLQ?H2;~2Dx29h+o%8-mk#g{N^IY#!+22y1 z#BM6iyQP0sU;A6y)AzYm;<;6ZE1%rmY_9fqW~F$RWjw^|ns*#Onq<{D=PG^M3td%io(b-<3|(6>i>h z=RQbj(5L;72uRcTeznr&l(!MvBDnd%)tkPxckU@MoU^KYmi$bq`8#)KJT(Ocz)j2T zx#G9ZFeGTCWJqkEedKHB&AC~79tWS?cPURlV*8ArdlN&m)@ASex#7gji#zxHSo86{ z$-M1vPM_EvvS&TSPnQ)<4$M0HQU9RTqSQOD@@`I_C|%ck>*-U|%SL+_thl($B2jE| zh_CkY+FwhbK-2zpkO3*r?rw=+73cbE*^}QV9hd3aDxKPUX8oGTO?{!pZ41J}J$+7Z zN}WBq?#!Owo9&mZDjpRG%~F_AH?@`lnQ8{$yX9bhzg(2WRBU#%Zi3 z-{a#>sIkU)Y+YV4Z(_FgUBfuhwZHx<9$RwkvwE92%i$T14AXU1&*k2h_5_?9+_k?! z()E6pjx#UbnQolxRet5mT7><(f|3@yM^1@6tzRH+!V_L7sV94keM(l{vzJ}pdbGkd zx1`rB@M6ta$7-^ryTm;4)I_6Q*S6m@x%QwiQ*6@gu8nzrmUfpv6U_2?zw(;SEGZv< z?YC;vW=;9p9rd5#hP+jnDwpZXU{{khCaIrI`ktw&%=a|*DYrDZ_N_~CZ^0)m6i1eo z&f5F6R#P}EQiK;N8osNk@@jkhJ#M+>?2aojj2e%<)`nhgo3Sl8;KGA|@8^!r+it$2 zQ`_**IejNxu2+T?f@x>(HNHC9QFCL{)SnyEPT#rG?X+u-9FM2xTs3|-J8Rpt^vd^s zr``omiCVcw-dtkyv|TSgt?`XYNs&GJ+vxAoTYcege+h0jS9`fq=Y?9Es#9rdi7Fz* zx;;}%!{ylQ0#z>Wy2BrSqGiIJk?v&(|0%!?w zY0W03T^wh;%C9elW~IwZHmC7w`IL1<{DqobP=DA2CN=EITJ6`auU)X& zt>LoJ%tsU73Z4DDT6PEo@&*(P}7FuX>O3!oWWoR`!W#*!! z>}7U=Si!Pn@~tlAF=Fkm`@}Q$$iT z{;Z#pzUZRUmUB}VMtqB0k)?fVqn6dAe)c;V6SEdBJo5P%gVPL=pU{eXX;4VGLw>Sl zp!I7%Z-_fW=!l zRO>ATnw2fBeBZAqTi)7xJp9hgsn=ZYZb~uadUTm(>1CE>+0$0cnY~j&%VlrpTs4XJ zMhOQ`8u=GnO3gj*#-ncd-fH?5)fwxHo1bitJZ=8B@wwa?uh-Ky{p3};JN2r8Poc)d zn_JCIDhyLi`abem+C77mSZ71uuPWlmRGF+GmAUj9sN-bzSGp(7;eA23Q00f!W)?3O zJY-;NU~UwiH0zne=kC1As{(62^{2;vR(!%!mSQftT(m!PR^r7F3lZBxJ+FSrIr0fz z3Vu3&Ztb0=YtEl|?Y((ca$}E)uc3LC=Pd3SJCm2}_|dey^7@^ES&KiPKBKT&$*!dK zeC8a}P(NOe)H$Y8o_c&sxvw3sqI7%1s~P?_9`ho5ixyQnC@<7X>!XisQvSmlehu%Q$yJldtd1&C~KOJ$l;N7`BQfVzZUxEE$fie`mayvV5M7 zu+OfvxMG)s&o6v#oqbB@{5+f25{&V)e^t*vt@*?)YfsegWe)t+;ZrZI+0-3nvg-9| z*Gqe62F|>|9P@R1ir1$DKX+8mJk`&(amuAVd(XHFKb;dfU&k^?EZ(j*De=?mbDT?k zjn*6K)@NkRSU=NEM>4i(tA$D8+BugsF3qrb@@lOfq#C`nQs>aZhWvzMA%4FuPlAT} zgoR-njg5CRukn!ivE#^r{KT|!*-WvTc;_`L%O0;yT=g>lx|C{V_02QudGxZyiuOLd z#9;31aOs#>n%1+ux0vtV{PJC-bcV}S^NDFS^WE2Iytnl_a`?`X=JTCBKBwLvdCl=K z25SAyGpp~1d}3&u@xQ)F6eX2oEn<3EH|m{=E~P|WM`f^A9%?v zP**j|CqtHV=l2q?Pv3RxuSk`Z7oV0`ejs$UZ)L)(&d9kA>zTMzOp>x@fBby@oT~3L z^|(TVON-AO?uB#~cdnVd(@HjlGt_I!$~D&^*(rbG8(!^28?$e`nQC<0wu(zI{r0p@ zA1SLB4-Vd3v}tDJ>NY{Ivp$;+ED`2PjC#6j&jM@V)IO_{n!RU@n(rn2JpO6rr*(4S zXT3z%YA4^p`sU3x9m&*mK}02VyWu6wT4SG5*~ZZ-^A*Z8NTjExbp6r^frUqx# zi-XUspA&8JPFv{G&UM=N?#?+Id+_Ra9u+Y&2!S>(rRFVoY`jeUkzCsHz?mDDj~t=+HY zIZAjcJzQ%7>D;>gwmNrRMze2jsp728k7cfhqnkZuOyOF6*~ix*WAUrkox6iXVoF|W zFW+Ai`K?-2hG|!isY6-nWuKUr=kH|d+~@v$@-b`rbm6BnKii&mi+j49XHnK!!>D&L zM$5Wfx)?iV?$S8uHMR593{{UAb}7EM=Q|`HJ<+!MNe1uHr(wot-&dvbrW#oHt&K0R z){eR?^ZK05Zl41Ze7j~mjhbkm7Nyy%^=e(mtVI`M<2BqFg*{(#T{yJV;95f1u1BXg z+iY3gYH%s@7^L>Q6Xto>O5BqvG-#<-wjQK8`)$frZJ#2auFN%&lgl<5E|m(Fk(zME z=gqY(Kb<3=mAt+$veIYPjK^a8w+O9^u-6MzisnD^!l(B7U(bTs7k?i7;P;`l+;aM} zg~yDh@UbNM+E2JVQHtwB+nQ5*Ux(kbldLbVUcaMl^7+Ocmws=u-@Z;bV`=r*#3@l_ z8X>2}Ws_qi1ida6W&}K5XRu}JGO6!Qn={vDAM7@YJNN5qX+l7E)Gd>h!BWqHU7^u) z=%QF_Ms}&-oE805oS&gycyM#-I*W(1eJ?EbZ<~5vHmYc8K|_?PtdGge$W@81*)3W- z*Xi%?oRqCJ+0*i0x%y;@6Hi{>dB5)}Z)oOWjVX~OmMIg{-4a={lG_&FJ9Osk^&R&` zrg?fXK9Rn0dE$x-)t{9zrJN(%c03NRuVGjaaK&jyP3XFMTgz)R9E40R_gN-Fo5O}P zL*E71xrj`1@5+QWa6T`cJ+s~G&;C2d&Yrotr^{dE{_~R-6O3%UhOq%~dD$HO~On-R&?`hp>DfKfR-iMm1t@57rlB0hoq};MUU@CK5eKD{4W$pQ= zV`Mw;s?GbX##CJF-uz-?TIU+?H_v94ax0Yz)$eJjxKPM)I?~#}kyn|!QwD_6a zR#_T_nf!0g-u&1T{zDerU%4Z@@@lMwhU`&=C6jhM4)v|--Uc4ssti40a-?W z%wEqJmwJ5Twimo)+jPEXe@`{*e93x=Z`}4G`+KTc=4n>B%y?j$#&p}+ z#-^V+zFDSiHc*?%dhL-`xVPNF^hZ|i8zMhW|Ja|f`LX_Exr6D);*Z%j`3wFy=<@fq z&BV*j&bwPQeMLQH`hByLHa5R-?f53|OvU!B6l=dJD^|kPL#^+42@{Btyf`&;$HF*+ zZ)@fR`|R7If5=zp#YEZx0l!JFTferJ|{mh8E`rCq-E%`LVn;gDmK&ve$OXQ#Iu zYc)J`*=;eeD&J+F3r;Pv{pnhabFO6eE!t(Au%PAKr;8-=%5 zmuz~S?X>$fe^xD9XZE7lU(e2d@9Ilt&pTG48uK)UIV!7IaYazylaud<5_{;C=; z7d1hL3jQ1)t^Aj7xu)Pn25v)FkKh zsh8DZlf#-DShdzI+WVS!{#D-jSA|ZUSQwezv_;EGbg9qmZ}YeGPM;}tf9DR#=b5@u ztX!*?GZZLp{lo);8*>wCNC@8vjePlL$SUe6s251ef~{|aR3(@B@Y zI&U33DYWCF#P;b&T$gP2y*-^#+{o1X1ayc-bY9ifOH2(eTU6^*FU?v9DdC<51qk{G ztn*yOxu^JFfbXOEYiwt7d8K`qil{cav~@PWy4+(k)i;qLS@T7%xGdSlHFL+(-~O7( zA$ykfPM%nH5P_iPR>#XsVWybcpY~LD1q!cxKS!H7Nq_|>N?#}r=`x*Q8rMxUr-jA$~ zF4?EN#%O)b2CvJZase{RA$z)^Lyue43VgZOnL!7{YUiwFxV)IRFjaqk#D;0J(z4jk z?Q`*nx+M}jGd8=|oHb(hcFWGq`hWey)}COS#&>3Rv3$<1>RSSkoqCH-_;S?MdS zuJpEZ=V@tmv$tjOCEMTHXEGX1Ssbxx`uR1H-p{Y;Jl(YUe$;-|L#l7Qo?9hd*5Wtg zI`eGD`s$J!JK3~%@IuQ<*dV*`CsrHmM?cF@#h3s$E^emmuIk_q&&#-~nv1I?2EmsoczZKcsn7!@A z^q+o@z4xy1cXr#p<81Th^wr+?rItQ9cJjpTq`abIC(rKw&+zK^(*4ojrhB)|oWuP5 zh0Ff!+l`B+h3^;moRoLxaKOLd9bdEVTriH%)SG#kf8y->jiR$Io=X?~!h4jXI{3wu z^Uq#%F-}McpDkVV_i}u~8IOzAK{GCtES@ZP>c^8mi*L%y%uNc5T$<6Bd@rH1W4grT z)9!x8%tv~IK0#ZZ2hQ6TUSgVJni|F>v}4_r3$dH^mCXK1Ke5lW*_t-nv9;jrd4`4D zChv+Z|7>A<^juuwXskohp_`HWPuUCQmy`=?YGf!K-?A%&`N^V zOfTqk)9lMyuM`+A#ME8795``bx`H>~y?x@lmp{kMyz`PmZo#-j}yAJM;1EHBq-J zp1l^^HiQ53*=wRvp~VGljh5lzM@|2(Oj{}zI@R}!*U=^aEO&v4gU+_Em&R(ZdoEU) zk-bQt$y5fK$v`EYuHmIFj@Rd)?Yy52e`nA3ow3EnJpzoUcV5%w7ervykFtp?&YCN! zEjCXzzqEBp+P^P5h4W2)pBy>xQmo^pV7K#;QWWf2F*h-FQR=PwrRVVe^|VcE|>MW zjhXr#uf^Jh*MQ3okOuWFMeKZEFGxkq-kS031#i$Selw|7u9*pepP}U;v(4Az9=sX_ zt64eYJ4==|L55B5r}=t%g-9i2cXQULdu*6~sHB^-LRRTe9J|Mc>5Z3kR3DUpWv1|* zZU~-Y;xkDqD#c3e{gtB=jb1t?VJHI`&=an3_J!2L)eA02b=>@_bSRz^Y7EF+I0tMS z$bt`Z6&Cn3WE_%GXSwpON8NaO*yl(wq3i#aLtTTUP|3;Ji*eeHOf{cdvBwrc+Oc@J-ApP`dC?CrVC8*G}AkY44ZwcOYfjtrJX@K`UEbms#E325t@&hF)-; z82Q3m=fHyG5Farel@D{3Z$9~O`jYKDf6hxNT%NrU8IYjTA&Bg0uo9(1{yck8guvq1 z2(amM!5ZMEfz=_4+0H{Z47VVlucA zS0)@NjJ?G_7gB)l{R?#~ZI1L(hU!DT=~>n05;P#>EaQ~go0kxTT7poLB^o2xxq&D z=1dFT8k!+Gl_yb6X5xzP=00It*V!uNU6^vV`g7-p@_mKfvx{9j5AB-rrE{Zk)MK$J z=K3DzgnAR17W)-kN>Y0p{OnkjqUaJuuk3TW)@~M|i9(5o_clyho2XTA`I;`5l-8$q zwXI3l8W*kcNeEn{Fzb72$Rrf)60Z!BvCpgxXV&sylhB&eX_NtfdmVP7h~g z&SG4eR5&Yb-NFR{$!U4Mw_PH;*BrE-k*(eF+SK|4Qvg%4ThkKLss9tn%Wgq(k}K zVvQ!Nk}fXmm{T33*q0d<{7f+2Wbss&ZncC%3w9joSv}Rub;^mCtV(X7$2xtKPNft% zx~Zm?s!D8{IrSvF#MG%KsuCM#%DDA#^a`Ham}uVGxHi+*^s1O}MTBSpv&F_4JTVee zXO^%=Y?{GyEret0%yW%lI?I?%1v(`si?t@5%S>IX)hU*9a4zeT$g>(x1GUA9eI!D} zR8rT4YDR3Dl^mgYOf|K5QIO8E)FMZZsZ*~^NjjAlt}@k4HPx5XO()G=m|a8Az};fQ z>_Z2xZCN(!smF%d%}lGkGS{uxIIHcMYFaFtj>05SlkV;~-K3(-0ITURo&-Qw=z54;+T|UJS*{M($O^sQiK&MkDS^S zwq9b}44$sNiJ?bhejMF%PkoJUcGSytJGRe0Y90}2xNe?du95v!^N5+k9v2@AP1c!o z)hCAQ>f?wBQ>P_HI+VT7!Hcs<3R@coD zVVZl8S8dAylSwo8zkOY~|KPi4_1pf0c{^U#IU_nP(fSx$wyiK*@2?rh+>Ni?b6ptj z+_7f;`)^!pM0~|9PA_)Kls%Ji(B!nK zp|Z>7qC7;YG;_+VtzO}b1?zUXcE&E3+_BT`&i&$l$F{$#Z@zo}Z|};=iAj1FTaKSO zCaStfSG&W?`W>w5y9FKfxPSHA*|IzLi%ZUIN)FL_cUU4Tba`3F+!;<=HQha`XFdBp z|H1d-@9L@3`V_-)Esa4D7pSzGGUH6mI{YLD^7##&r$G1&PxH*IzO_zXQos zrxYp6Su@x?p9PxewTHrM)|>18Gfc|g*}Hf$)6I*gcD^_**{q%4}8<^+Z8@n!BZnW=HpuJz7?SGxD&f7ej8*p#~qeQ7VD-5 zu9Hbi<@MI}xl+AkFK&G)oqo4$W!-H^1E=uA ziJo(PZ{MT`&HefkG8~`xZ~eAxP-?mLCCTri%d*Iq3aYEN7%82Z)@pG1G_U*4wUC6Z zb8?lMMdf3m8K%p6Ui-ZjO!IcooG;FC>r3zEcZg`-Z3Ui*oVDr1f(*zgTrVi!l>eDr zdnUKapnkb~T$Q#@rs&_f1?x)Ia0E+Tx1Tq+?%B?r`$2)vt!uEf_-FE6=HFJ2*Kdw1 zX@0OU=FsldeIdPd6Svh~&zk-gT5bD!raPKgKZ^3UTD%Z4glHZ*De78ljLRuq&R)ft z_tI)k+@Ad|;YUmMLXDqC{!SFz^>V?w88c>x2>V0{Uy(7X-1ltz>_2kv>YgrC;0`bA zIrn1*E7uFdrPW)GF3(u-U9WA1V`yFAY?Jjf#g?>n?bv%P|3uffC{`~E#;E+IkbDAJ zuGsS`c;8;APd{%ulhS1ua5ZY(_q9i(^ww}_^vX~1cGXIbFOCKkQ+KLc1Jm;V^JmT`c^X9VR(0IFW$ZYnTSJ(HgU#gUKTB&jIK1ko@UT73cvdPt1 z=}`%?N28ae7OmtB7cJRnZ6u=g1YY#0r3OtkHMl5s-M93}nX{Tv~YdV(E{UD2@aF4GiF_`UVs1EUvRGb zEOdI-ih#SZ2EB8cChal}X?#0#Yrx(6%b*<=tx1cG7npCH(Rn}MX*49K)j5anIJ)kQ z;(rFec=P!3-%|JTKUD9W|EB)WwyDm6Q{yKDNv+d{4l07?Et5ZS%ziM@!RUP{w7dFk zs>XZM%KFV~AtlSd!%+<7lCsBlLWX$fHL`INtf-;c&Li@xjJpQ^p!So(fPUe?v2GoykwZZ=nY=oy%i zl3nW9v!cI>U+A@>D%0XVR->f6Gq>hgSQ>om^_zsvW`@F!IX8XC9@YD-_RS@7&K*>V4}`pXNIe>YA-guQCjV= zo@3)%U9H9#udAi5++kgkk7r!wn)v3yq^m(uiH^_37%f{Jc=>Bm&J-&-tSw@m;!(ES z=&{rOy?rKYJhlErEZCX7#KU-(Ky}jw)~E{_(hWN_UJC0O>N>CsoYwj7s=ZUH{PNvt zn{AlAb}@U4%(^z?%*?zgJ^c2U)ZhB9IQsKdw-(3Lm_p5og*{ipqeCoMLZwVZ9{W7r zuDx^pB*UzzJTH57!EQ6pUQYj+3(co0rfh{jrMLAhla5PW-lAHxHfY{PqseEr`!qY;PM_($w{gqor1RSnf|`4c zkLmnojCvrt*3@P0#)Uqn`Q3I0I2X$aCg0jzAT^cQxyyRBo9dgjGAjd}h0NkovMmL* z^^aI|F1gOe>UGd<()0feNk)>Z?}|odwNFyp{cOpk8P~No+9@u!{TEexX|qp*RG8tG z8P>tsrP3WUrUf^0>&*HT*Wz_%*P?_wNng6w*E{jI797_)w))MXBDNm#pdV&J7&{-tXTemb|6+%MU?s?+PTNv+ahxz9^PQ)h+ntkjMY zR8QZeR(ek5ZQ06YQ!`IsC_+2V0l@$oRc%lh@A;mxBL9)L))@xO#1ms&Osvhk(!3 zx4#}anmN_KDDKK;5&zGgGhQSM91D+G=aa6dUN-s3F_Vis&*>~n6%`2H^e81mV$2$fp)VXx9Lu<(N~-I{y!JvPlg1Y#JM zbZhQ`5t0xVh9t;Lh-Q$PAfv!GpfPyvLM?u1?y+e`n$wC4tG#x(F$m2)*!{U}a#Z@R zU^~NU(*s$V??{DPB^I|W*EndbZ)Yu2{w?OjiNbdUH8YMZ>-}ICn=t$I8sW;)rhL5( z1_lOpu?h9(=kW0G|NPw8_@BY9z`*|bIrh_Qc=)GB$m?wY$%7T}PmfUh^qQIZU(JJr zng#Xe=kWjh+!?R8AwY43iFJ?V)n>_^8Lul@WAm5qTzt+b?^NQ{cV2EqEB?%KZMeYo zZS(R|wsSt#O?>mb`%8biP5hE-%SV5eZ=M%_DR-`7zsCj)k#EIEf4Og-r+*2DHZgW| zgD^#iHoBXk{_QX4uAggCL>`vqq%XhsFMRILvS**O|1%uDR=nSRpMZ?q1{t}HCU;lf z+TvA|RQY3mkH5X}$Ij=E>I`lE_#acB-}&Rn=a2Rh^Xk+e3!gvoX8@I`&04(XPh!n3 zr=KOQ-|hb&VK9Zef+4$+v7?fKgK~gVqKay8A$ac- zGx!put1p(gz5kVWPB6v(KZEybiPbWBcUHbV{!TMWO#4od#ropwx!jVDUf0(ieHAis zjo+u3_p0Y$gbs*}!~^qV-s`*vGtPYiNr9N3Aq>-VpOJNg%vrY2J84JuN|vP!>wKpQ z1~;t^U45|ZQD@s6hJPuiYNi*KT}&2Ayus+)bE5DVTUI1TwYrjt$Ic7q+DeQU&gFTL z?rbH|fW(nRmOKveRTJ}beg$g)K140fr853A(>Juwn=5Sgw{lgetNX(YvG@Y^-^c8qcV~!n_WXCD>~O^ z3pe@fXl4~(nYmKM*HvQ1(U@h+YMv|KJ7$vWW}FzkDR<NNn87O;aSs;dfsZ8gO(?*gvQ3M4lCLvB5NKuH8iPz(^k2z z?$l)$Pc7+G(mwDtQM4~F#AlLalXTIy4CTWLCzt-3ADpk1+CL{|NrN==&emQo8@CU9 z-<>wugo@T4n?2j(ippB^W&M@PGZhc>Fqkmtf84D+XJSXIsOp-V!2xMk_iwOV_Rn-D z_tV?6Rv$^rjOV>={LFm&kIyN=Cz+=vzkC_&m!vY+N3TNWr;yjC32)A-$UilDb^k<> z&1r#739crQ4KqH=_}Z>+Tco{clS}$b`8zoqKXlJZJ|DYPw|4IWTNCZ9$(Lo7JuXQF zOrA4YWn*u0;MyA>9aZ-%+tVPy?$);XJfB)t-tsH=&%1NkWF#wmI(|+sGhSnU$*;#* zueYnYbZ{s*FmwNwox;(bViJ-aSg~yFA9j;B4cqol&7RY`EopmbMbBAd6~>My6@AK! zR$ISGZH3!tyX1N)tfq@>cc6U%$N&1JeM;qT9>tM?cal^Bn^+p znC)?s3=Hl5tUaqwi!D9Sr2m~+#E;aodUJHdlcqLEcl_#Nf93gp`@CaYd)>CbxmxG( zY4dr$>l+%?q)&KhUVL&m;rKMW!eXyVm)~*c%2o%NBpeZ9v9?{cHpFX{WWh3n^QR@4 zH});6G`aH4x;ZoB8uzbbdm5q{6pr3DE^6EG^@NMbT3dtTm4dUfvMj|jAC$~kA}sE6 z_S7uRf@cOtLLSW#{gsrpw^E7yGGo)}TbpmJxDvb3TI2QE7^keVcvSrf(ub}BF$5S?HbX?oo>*BvX^e6MF^ET0OXZz)ZZ~WD3i8lL` z+CL}f(!4EK7%!}!%j-Co`_GFk9uSIu?i`Tr^q;{>s@Y2X=Y=bhAhiEn z>%S7Ho??g|?msU;N>kW;6RxVIt@`9XbzVi;o(@idj!%L9{aVs2HXA>8&k8=Je)D+B ze+GZ~&3%QxEB^kezj(Yh^hv+A&4;(OHi8THxLkVSx;bmRPR*ldlRtHCTef+6+{Yz# zkGGryp{)nAvJKaZuDH^;A+EFN*}FqJpIBm#XU;0Rm-F(J@~djg0xq-T@3dn;$U5d7 z7|khS+tHR@7?oZa^}O)b^TMd7u1{NLJ{HV>DkZ`$qP=3R@s72{JJuTScw6if={@h! zl1_G+M2jW!S3Q{;x^dtB%=z(KPVAa@MEmI1X$!W9TYR;hvVu>@9bz_wY_Cs=R8GEZ z8b8Tq&F910mS+l`J@v|?v7aMPF5>6WvzkwZbJPDc{*HLgd*azOXrCy2G%H7g_ zQZL(HAwKJ$(d9?n-!A-)VmI|ay8G?L%zr_~5nfdakJWE3e`=@O@gv*+9{)D>Su#FX z>l{99J}-8CqeF#Cp@vTK>5c=NK>;=8Q|GqLe;?jjwKwO<7a_O)f~NIpw_+=<@|*IX znx3opDSP^s)UC>L=gv-?)Y zPy2Iap9!1V9~I8}&tPCNW2f;cy<6drX6{=JPHWq)oKcG08|y8h&}51n?zcVv%u3wf zdHZF4mt2m;$O6sa);W`Jc?&riOT{gwD0INw7U~Ym(8C^%8Se)P?NMl%EtkS$CfU$OSicmVR7* zSMJ$zmRI&BRjUrG%LaeUyWfk22B`#df*-TrlQUSQd! z{bF|~L(<#Pt$%weHpa2PESz}Fw-_A2{u;Bh{h#oi4L7Y=cB^bcddQTVEAd*fTl!Dx zot(C>a@!?4a0vgMW9+WZ;c^yi;al&I3yx-4ii&;rTa#KcV|Qqb+?x51xf0S@xnyD> z-u1a@4DqkU%$3L7ZrYw(+NaE(fJ|NV5LvBeZCvMWO#| z)Bb6;Tk=0o2W9_^{pW1_k3l`V8{*maT)#uAv!`C!*u4w@vtIdhhlV-fa^;#BRNQY<{ioN9X&X-2AX-LgK$;(Kq;S?n*ip zb9rz3HMxjy7uRQ}OkKSyRA}Rgozg##_x?$Ga$aM1W_(xPuk|P7!YUu0ldKd`Z*Ujt zD^NkymA51RGbof@ny)g`;7zaOlRCKF6MyRXu7#xP+mWoF5=ET(CeHct=;Mp?{~4C% zuw8facysHWVEUl|j#*`ADk6jq?2$hh{CSr7Nxf`|ch4j*OSre5_;_NjT9lHsa=pbz zdug9f-P@-A*p|QQPvg_{d9v%5H<_4s{)(0VYWC@A|D2dh^8&%4u=u2j`{$NROFZM$ zj+ve>GTk~cNW`$+RQ%L*i?m6?mk!45w)b(dyjRTBwZ-m*;Rerxvpu&q^M-Qe#`5kv zT(&9xpUyS!<9DofDNk=yj&Yo}qS@!cjEKUXu70)IYxHH-E#{4!HozJW6}X z(J}wYTSdX(8@DXh`YBHJPUP;|$NT1mR>!T=KepLO?9WtYIC-G`^47L<=Udi2o{+sb z+;{>g&^l*DKf2xBISUerx4wx~8gzUL@?z<5&Hy*iK;?t|6F#?fGj;1f9KB@J`L1GD z)E>@%i9Hf;-Mats6`6{LJrCN^^}u=Uim4g@bk;9uWfZLY`Nd%QqE$C8pITime(6;> zZ-B7ii6^f9UrsJsKW)_`&x>dO9ADIyArPQ;8=OgOtmC{`DpV3pc1_f|)dS5VIS~u2 zCq8j;Th4N4)8gZPDtZgo2qk5?P5z<1+;iHx*R`{nKBnwUYU*mdJe98yR2Ee4JEU-C zVRhW~<;L~a6LO42^7kx=kn!WSuGn~IVoL2a<0VIm18h$m{4EpJwqbvxS6|5^`>oJo zDmtrNFlSEx!u9<^%3QEw`F_vDY&NA2TCdDzRyaSuBDDANi=zJb8c}&$>fVWPH2$4a zq0YZO=+-ZXUx@)%bSM6tcg-Z%ZM$ei^?H$zM>DfZCtEBzWvCLharMlVIT7w!jB$&y zCZ}&)pSAV+Rqd#{w9MI|)8zf)H}}iu=y0@sS1h@kX?yua(BdC^N?GDd^A-hEPJH{| z)Pv^#41auJ?a{9frt&_kH9V4`>;~!xfRgoGmg1wAYO1VWocyPoWhf|IZYlY0>9z*d z2QM}qHK+}ElWb%$Cw#{8t+Uph5V*-?>~MCS?}fF-AB>i`$0_qI*~C>4|Gw+Q6@&RP zAKKQ)t;%SfQ}K85L!+8>){kehX0CY3DN(r4eap(EHob+e*)03cX5aeq=b!!o-4hfXl!UzndN;Xk6^1r9w##;_7+&s7$%;^V)MK{F zYSn_?@}&BcT+aI9TUV`f@d&)6Q^sj_bDYlmqvCdY(+YQ{xv^kTPXWx~5BFDfw z$wi`9$fZ7M>$$8&OL*PQgcirfCvhu$b=|kN^|E!!cF!}xE(PM57ypI?af@2$vr2l1 zJ~D`nVE(`xQubRyY8q$lJjsMfb6la7f7z8M%4_EIFI?L%r0oNX>vJL5p{Lb;l}rtB zw>%%Gm(UUVz#t$(x{cjSu=bR!DQoA!2kTo{UF3ev`I{|nsG+Qq^>F#^s=C(9@LUzS z%5{2{g27F91KcfS5>i>NJI_16Il#?R^vGFV<;6)ov1i!??;dEq9Kd;e%C5kd@00@~ z*g1qa?{I)FR+y;w>{wA@)}lpPuy*kNwJa;v8NcjPIv{1Nv*!`0!t9>LtG!5YVgFLE ze_REbzkd9Ct@du-T2W5*uR=R&0)*-V-kknpRBC!=X{h?kFYZ>B>C;nA=0zIc+X3pz zZ!bNYQ0H-2C~e})4Jx63o^E;Q*!A36>(Jw~0=IX%wMoy|dM+S>x986gSbmC z*CoffePuWs{)AuK=EHNID^HZ)%()&A+N4r87v|2bXCkjnY82Apzr3^6C9=RI)I^VK z5yzo>fjiG8hn|y^2wOZeK80Itr<0|Tyy3&LlRppWyH)Aw-_A<4c{G_p)iBB=;!)s& zx!Qi$J^iz@lCD~c28OQcnw9nNb6~Rk8}WaYFTd$DFS)|yzmbPEN%U#LjP**EG94=% zHk^H0b63*WylcsShRZvXTqCNabY5+mr*a0;YUjKDwPC8{C0U;^A%>kspmwmo#_V4k z6fbZmn|zc}5uK7Lv{>WuE+ZjxhYO3k1O%AcJdS2hvv|(1DOPg#9nOnuyzX@62g|nJ zJ9lzwgVWw$tGHX%`Ip9NFK@iQNlxqSs+c3|Ldr6a7DaJSuNMAQk*XKam=eipcigY< zW~OM$G=r&oDP*7oJ^>^!h1Wk64Y3?j+xrr5Lc^{ zWOS764zwZrT7;{_>fE;8xNip6KBXS(>+edN7sHwrdep?DL-@ej-k3fI(?w2N%YNWL~J112qqa@+XKVUtSX zOrNVTr)CxH{ic_^<*SDhNz_m4;Ue7X5 zr_7dR_&aCCwN-1HERGk7WPW+IBve@BXtqGIqoKq-?QgHLLwe-BY>lH|tCr6HW)Uow zWvhN-x~%)l=e|4)D|?K%JOqErHb`7tuyVQAa={>#n@=|?aBhQkr*YW!y%&rtN5HEEc`BSW$0i#c?+6vcJYMIRoVTwT;%ZRMZQD&IOz|GcU8+N}Cpzs?B7Fk__cRg;nICmCk6UDz(VXz=sC?f!4}I3eI9jx z`{)AVh))d+OwT-F^ke6wk~I&0cT7k-_V?^mowyU5mI~i=XG>XM=6dOK--@Np%M}jC zr7zkl+*IiEXy#Gzw~uBXHSS6mIa-u15_{PcJnD0J+lnbc=L6QwTQ1^g76fe+n=3}J zY2P=x<~n(*^DEYQ3o5URl^&g2SYM!fKFUR8+s<`amPXeel)gN1Xu^rFk#h@^=0si> zt7=?Udi-zL?OB{cHea*lI(D8o_D^^1(WrA0&K|DMW`YzvkBNN6-m4wAXU)o;#rI#T zz0E9fvQ50caz^*Tsz>JoY`!v|+ws9DrL$_oYn_8BdWDbDz$vMq?25^wN$Or+Qy<64 zuBidneS2Q6+fpf0Ssbq!xN=T$--qWVvlf`T9J+o=#&qwN+6>`k$-2dNkA}wV3Gcrx zlw`PK+*LcJd_{h$Rw|y_zRB{54#)E; zhP}y0e!IALzWB!QD$nR~sPC^9Gx=QTAXDDTFOSov&q_Sw@f(_|OFt}m&XX+C@3zo2 zQlLw?`0`^$rg*_)x9sP#o7QhkPF+7QcHxo4sK>MC&2KBd{K&19q;6&h&xT4wf=8JxnRI*8DzH;nfy>ij~XQ@y2&zibp(cwO}Y3a6h#g`wh^7@jQ zRI7AY;ri*G%L2QSAI;ph3e?1^*mhOeE3&V5R?$plQ#E^N`(XRmdAnSUMHl_rbH{t; zi9G=dlQR`h-1kbgcWlbgnm+e8^SS0(`+p`sT|Y0j^`qK}kjv`59-^1cDnpLu+zY)C z=fiXLAGh(V&!NJ0>#e!dbsh)WPJLceA2?BAR=l`Mvm1w)PL22{wUC-Cd!~OD+P;yK zgX7M;S#DLyk0VW29+dm({P6rWtwoDo_IJtMS`Tg%+zz~K<{g%-wWHR?8X9y(nG&g0 zd#kPFA_7Chj5{4!-H$xeZ8GeVn4u_e-i`m#(RF@Cub)mTT_N)}{?Eiu*U#%6`Y}0e z;s{m!~kFlh@Cg(VGq>+~`Ta^8EQ#_4KRi32V9bL~7|f?G0$@c%W0ZrUsH1#jf3# z$+`6T%*jm2B50Yhr&n0?{q(xbynt&HK0dOHYqLz*>k-47^+|E5&_$-JJQ5RB{L~dR zzr4tv%HtdVXCvITKfb4boiMp|_4L9@1GD*=Vms#?n{JodZFwe|MOY-@Lvp1|Pm*&l zqvdk1jV8+$T-w$b+O=rgx>dFjR~JwHEVSM0#~Xor^`7zbChb>siMX?BX3+EcXK9(G zp{tT|SN)j?>e+5}`79K@SZ&VIFK0blphZ%?R@4nmUp<#kvU}B1JltojYWgbhdU}Je z!ltwKHCou1Uw-!efG$k}#Ee-sUd3#1cl1AUGd21_{g?-d*K5@78 zsl!?4q^K7i3nvshD19_Mt@z5RuE;j~&bcj3Q3kw$2Oq846Si^7#vOLvrmDfhO%9tn zZDlV^>HfO1`j*m?iq+fs->WKZW684&{(W{<^rM;3I-=}K$i1bj4NC3hU>SS+*ZvPn za=N?AN`=_%R=oK&d-JRH=U3U*D?W*qNXc9OFXCt3akI*bCy{Aw{|Z%i7QZ@jI8x?t z;5(DBGfmgC z)UGP{`O=B=ru>+wqQ6PoIcWY*O;)AS**v?>AC`-DGhQwl{nCHlG&iWKpD$BVDwaDN z_o|;ceb!C+*p|YHB4IDzZCJAPRQF5%c~gGAOi5p2=f19ZqPJ4azuf^0mk-obUEKI{ z6=aBGt8L?a-K1r6mVP-gnG-TM;eY$rac`SU))#-{4%@9woFBH~{OTL&S96SCZIk?( z&9iDkq*-HO9Oyp!xR=*mLnn%u2`g`(P#?DO{OXAGt8a{7U6cHp%_kWdR_$Dn!@;ng zi^J;p5joYZP1WjG*MMcCd46qetBr!1epc!Gw7_{kHypIl5{|kuYsDOyY1=qDI=^Pa zRIN{JovM|!*wb~nkU6K&Hb{QsyVW=Sit;kP)sGKvg$&J@*Ueh1eCK=F);K8I8&?KF zTjSg=f4$h%CbcT*L&$@rmrGl#=j~k^J{Jl7b?xFva%5KzKfY1Q;P2Y-zaTSUy5Vex zmF+RB9u)dalrTE7NIyIDd3NZd%&a|c!Kl+Uo}(iWHg>f3pT*6kZ_Z5$QihFyXe|!z zx*xqT?!vLJlZvl-o{!YFO_}p`(&lSkVCEW-sMnF{>QGV7^O5SdF>}67D!zL1e7L%8 z%o>nd6wyeq+9I$5ZHQWsxgfO*w!N5oc+z#HPcQm#+z5WqtQtNRg85 zsqTY<=aN2z7H$0_(ic+BQaz{dL67^#@6SWZ1W#Rk(Dn3*>p6Xw$9+YglonU@`DwUY ziVBLJPx}3z;d|F}%L-+=Ro%yDzCIebKF&4tSv{Y{`Ugj19)0;9|2o(F<63xo$tu-q z&Bc&E6=j>i6F6$u|1(JVo_OM)l3ig~(f@57f4|m;{|qN)oJ;lpRPJuU3bTx^N(8|tvhD=V&AJdp~ZKDyLSJWY+*gUYt=`og6#N% z7UG{~9z9>A^q=8te+bBQ(@^){;mki`K!yrh1`3PHU2#3OUgYt%**~tmU9|^XcCMZL zpW*s!t$jAu&>Wt<(B=ZiG3}VEAu)mb`(?6Y-}SxRX|c#~&&tE;rlG4kuG#K;v9@?t zXK(i9o5CMEuDTZ0raP#hpgj;LBhZ~0830(2d?oHO>siDzF_XtMq z>6m%BXgOZYX}@$H}Js`RPgQC;yEiiorK&Qt_holgQn`hrIOYjhPTPj#WmMN#?+N_tT7gRb9z_$xnd8# z8}sLGwl&_C{ygXWxtsInZcd!L+1@(sFc{T8woa=nJM-nww=;j<&ir{h`13Y(h`P>Y zIWRr*=FbD^v$syG)0?>gYQ{zox9n_N*_l6YXY+vc8h_rFW7Icg0>>kdLxPP-6Tge; zY&5b3&Dm}H@i|31a)Nlrsx`+m_{wg-oT6{iaW*u0s<@ATNY#=0UpxN3{#NjxAzuDV zR_Ez66D8ldD@O+}n;^4%a(0H*hT=7+Jtt2Mxw%|ziRH=jUo-gPI9F)#{XFGy;m)g{ zi-X-}%Y};wK6mSRlDYU4%d%B1*9?{SMmTu#Ow(?P)<~%8ePFS~cuvaZHg#<|VV=kC zz27#tt-6{h5vF`&+Xuml^uRfDe%!9$e%r6ian#K#K7#b^69Lz$IIGFJ7PR60)ST4>r5U_W8OBuU+H zj%@*tcSd~h5Sdoov8|xxtMsA7xgC!Ct_jPm4V>7TWZRv~DBh-gW6g8_)dxZ!_9;so zty;Q8G{i+QLQMN)TFP{juCw+*1!sc2ey!oNF%CHMwT$oKwz+MSo-a2Fb8>a{^|v`V ztC=UHW~cIVmH_uhi%l#pg?Bni7)}guZ5BEIT){!(?E@u;j&SXJ3O&4Tyk9?@dRpD4 z#@cak*DmQh>x!2i>^z|N*lqREy2bIQPj_s|eAl;eUA1Um^2>-7D}H`$%i8)z)!wQ};|HJqk~J67~6EqLcHVDxJ7q6J-9#k=P; z%#M`Zvq&?u=V|q0&&H$7GE+ZtK`cIIwM%+xXv8tz71!40sD_5RZInLI8ObeoY-iWi z+#I!4S^_@CqWv4?m(B=Dn>i;^=#C_3G`A^FXm`R_pY}krqUC*0D~ ziAiT3P4+z$e8y@0udPn2B6r(|FT%5E}FIVwku0lQZ@S8@qT#ioJ7H+CDMxKg0FSmKFMXzZO5a z`=3E>)=$U3{ZF1gcz=w0^7L-|0G>rNL_Ag>+c-P(d~X`8ZxF)Lys7xEpJE?ka4UW@vH zAKRQ84jxE+Tc0YYlFoQ5CZ%+83~byBR2$E~=Jp|S{__2LZ#K{V^5axWU)+YMnAIDj z6?eYgx8kS!e}?1B_J!1^E^WHj$kk`A5i&E}HYfS(^smb2*qnZBe3S+bdq_RC)!@W! zbIU6mk8m&tKAjGky@Ghb2kZr}X&gyGb5au{T2dl6KHC~2`_=vAZg2=4xfgQ4Daq!P zn_-u0yNPi774}#5vv^uRCLj3&u>jJESa(3#L1|J~hN?4oS<7axN|)OKHJN7p4>sHT z+a=!AQb_A`E97lT;+>dyyzRP)G$M?=+U2sX(gQa}EsiL7d`MiuKJd?l6#Z^UAZ^bL zg$9y|;6j^}OMm@WZl4bAqi$R9<5Bw8iO@*adGOcGs8zXr0b94$>wR;~O)K(O{__PV z(W&w`GvmK1C~Q3RNK`B|{I8Y0yM2(&1GpudbHm>%faZ1W*Ic>lztVbMpGd`pwvVvM z&MWd#KL0kEx1ITG`?LJ%EZ>q+kAzoW0=Xk@zD=G~_G|uA`Tq=ZGk-e%otA0oGOzV- z+@w!e&5D@+Go1bFA~dPM!r4!>+13A8=J~$7Td-L%2^A;hlkNsWiM-9=E?3Q>+-%X$ ziF=&RbJp-a?JAl{g9g3`yXGn*RtPK zv;7d4U|-ditS@V3cpi8qbgff2wWp=vmc%KuV;z-wPkCLO9NR8H=jsn{eRIIQ?VGUo zN};oRz~g@Fr}&;wlkB>_agB6f@54#Y*f@4AlavZmloZNPH$BJpYw=TAsLv)XV-mJJ zvm{w!eZ=HOi`sxki?l;Ulmuq6)P{o`kKbc-VDrM$xmwjgfI;i%c!l zpt<|jHx8AdsC#Ei06y!PI#H-Re3p{b(o_$i0MaX1U>=AFZ#js)UTaw9#ly5uwOg;2U z`2AIRjrZUZ^RH0$-Tb_#%2C%$rSAA#jq?Dl-nupON$KQ^UraAPI+8dO(nE5&dpP-r z_Wp~j?0jz?u9Ggf-Bxf;@~}lZ&%-&!Am*}t0rfAEC60vTot(8AJd$zi-yWePoAb+z zxfx<+CO;QfydfrhrLlSCtv%8jYLYklr%YT;2I4>yB5}onNMTwNl%KW+9}X!=+TmZX2{xpH9W<*gbF zt4@5*;kJD7Ijr)~ttW>?m_ENOo=~PM9cq*}MP|~D#j}^EJ3U%)edoTL@hKVp&v{EH zd~V`?)i zpSoDbXG#GtWs+W!Y_VR8qF1uX9*8(OdCA=fm54@RCle|C zO@$|Com!@}C-2g%T{;RP!4LdcI|3g3Ryir+sOg#qbCmDy;6eV4n`gPvQtYtd0f|h0cXOP*srgjET?>n{> zk0Q035{0i4PPbfF=`ek=VS~Z(;5e^`J6m*9Yj@uJ5pp@L>1g(oto^;GTpvb=h6}ba zU#YS@dEjltIj5}B;!^LJ-)g2)p47^A9i6!S6;FYUgIj2j)V^mHZv|Ieaq&Ij#*y{? zPSma=dqSLa7A-g)yquX=pna;IjiP2sMBPFsuGNXtKJnL4?^uw|#D8}m`Pis` zWe&};7AuJEns|#fSmt^|*YV8Ttlg??`xBD8tSs3th=;G~p0@RVaR0?4m-zl@WHXBi zFJhau{H1fT(X+*`PM91tUUnoc{L(MUC6hOrES{GxsFgW4BHT7Lvu($#h^;2dYpZ5n zyLvyU_ri$}0s%ovm#?S>%gxmGGJF=~zG9(h^pw?hldt>Atl6nml?|0Z$ zk`&!zO(#EAoW1Jx#dV%NTFy~MxAV4~kupX@pJ56krV z=Wcm&wO@bH=0{6*w6K|0%D%snR~l@!a&Fl3(4AKy%#d@d(nF0`&AjUKTCFs4E`s}N z8CWTbfb?7!u<{i%o)_x{xp+){oR+;ibY|j9$qfuY`JSd!C~{T5yBfA$#MRYLv$?wE zi?Z7nWx*G@N{2;{_?=BF%QoH|Id|5XH`mhBHd~ui-duAIUC?wU34)8yok=Up_-Ima zF)*Y*!{zy!=6!+p(jP~yD^hapyYbj<`7PCN5BEAxy}+{iKf{v!N^Q%WFK1mWoDctor89yI|pR|8wubhHR@VM^SxpS66I{`*HP)hv&QVF>yRx_38cp zBMkO}42;YS94ss>EUe5d%!~}k_q}XPoOtk}kV0Xj(xinCSr%P-WMJgrv`M(=gK|L9 zA>-hWkn3KU89D74)-&#{*um%g@AN9W_U#{h8{@JC;vT)vZv4&|_@le!zF_{s5c%2P zR`H+x9m@Xfw-*1|Zz1xt->k4Ve~U)n45-WBwBXPEtpRoUw^rDjqw_)Pk<9^__jgtE z=enzoKlhzN(6v+9pCOBb_z-*Rgf5z&xh=_JKc)Hd`VTyFnl^B^CV6)-Us*kiLGF;? z#s%h6|1$)wcUTahA;QJV)QE=>zIv|i^YSaKg)G{)5`I_qPJSxJ$@}%l>RFa^*mt^l z8U<`z#Je5D{LgSC_FVu2H-i|%T$LL!1`n)ewf6ptQ@79mEwSOAk>&5lfg2)Z%51rWxC}~W z@9pF2X)xSVEEeu27sI@E+sB6Z_6+;CvH=bAxZ9X6f7yLc{J`Nezh<=r_7p#`nxeqH z+DPNb-Wxp3?n3z%46YGc#$Vp8IHA(mrXgOj?8PDL1r93R6%sy1u@7e}noRJCnB~Bx zyRxk&(9HFtX(unsr;xl~Y61zflUWYsu(@c@VSF|tWyWmft0udTn!5?!4cz^rkwIen zMS~k2hgnn@c;^Ird^q~C=jn?Tcf5TPR^0vKtHcv6t7X8nt*igx$E?z47K~@6sEDZb z{EK66b6ejzQ~LNq|8^PP_Kn|61DW3lGkZHK>Fj2nRWhxC<49zvqJl+($`N0O9dnp= z`xjqOWC*J>PM%}%z+xfaQse&&pL9f1)*0I!m}+!c>CUvg7n`lurWb83(GI*3x_)hy z->C%7O=m*RUYm1Gp)c&K+qEtI(~@6uJ!`6)c(w4wGM}?ME&93=W^T>0UYzZa`Aqr6 z{*V6|Zu)m|?w7uDL^yrZ47J>?5{&Y{l8y`hy~ECX^`rXVW0y}YR9J2Q(Y#tlK-W@g zB}Zlfi)Y82RVCs~3`bTkIKQO4YpZq9GAXt5AqUK#*H75G&P>Qtz{0_7xyg?n4wXsy zY8S*0yXluZ&UBK}dsnP3v`OQjxO~b_ftQ2XIB zfuU2df06vD2bDKAGgKdXaB=609ba4zE_xvr|8kbfj_$PIr*@uKo*7orQoZ%?k~Ooh zW?Jj27X~*+)X$jYXrsf%q^sBY!{>nI2@XNmh8?@VEo6w%&M0`)$k5q2Cv4d}tz9ft z?v=hx4>&Dmie{BP&3ye#rB1H>;hgi|8IMf4aIKp`G$mZNi|eJWaD%Q|?yj5R;=*q; zZ)Uv+Zm`qwkYIU`tgLZ>|3AacAoCfkJ#PvyO3XNFBUGfweJk{w!l!w!z6(|HFNmp9 zZ*aNcaV3zYz-a5&a2x*}8@@!Euewwg<@abY1C##(hc2VkwdNCBJ}`1H^Xtu99b7JI z=IyBPNO;K>1~(z?zeLOwTN2QzM7TyXt`s70*}`&o?q@2s{`+5zW&dUeR|cEyGO$v`aRxjZir!27fM>|Ub=j( z3%j3L$FDalrB8ob753@376kuYHF3}Wtfl#iH-olMc(XG4GzcRrfGI?$|E`+4XWuCV z1{tEiX=%Rxt(DPW8(<2+66k{Msb2S-VXEO)sb2$W9g-WQ7aN4?Kh00|{uzBU z=wH}3f0h)7mmC%GZSI$QLnbf^D5UTy2Gs@Lv~zEn(V#nVg5+|C0^JXN_*{UE&o&2 zAgu1+eprmL<_Vj}f!rkvqzu$9f8C&!e4;Gm_~dFOIg19nMWQWnhMf201&kBSUE=ss zLCyg=HDvyjGBDmG<6y?!$*ctqgH?~fxq@K_lb_1bsJT0%s;*A_G(+h^=o8Mb$6sxh z4qft2Z1RrJ8P=7>FT!%~pPpQ`IB~js_MTdD}C=GMg zYVTb-y)gHz54^Y5Juz(Z*2?*xOrPC-eQK|5*5xZYw|9VY=&Q14YtDTICydY|9A7%_ z1v{uP`K1}I4&JWqAGOIeCCqKlxu+>;fyIPX-=j5zO2ZWw<`@KZ9~udes+ba@;bZkj=pTQL=-=hfQutYyIx5waq$l zb;%b$ro$<>Uy9xrs`cF)mYlU^?$OG*udbAdW?F}N6+Y|v6U%fa&!#am?}TT{`Gs#| zx1{q#A1UQHVbQO)_@5ih>4DR21Q;H7p3rVEe#w##O4OOJ!`v2&?y%r?V9R|RmYf(i zc?T>Uwoeat*euI=r)0bQ8g?-+@6Oekcdjo77Xkvxmy|1g z8FnyL%s2*h@lPMYuMaA;=P;)d24cft;q zIR={iXZX{1!a)14nh|1;>S&TD7B1h(kjURzM{ouRr`rNFSsUO0-8y>*f? z$o;9;*7W+aI2yQgZo2BG#K*Mf?#{^G)+oP6d(M5$vgX<{clDY73=`KI?pUvNaEotK zs7h6nm6pRJ{ic>^KFPDkRv(_dV}0;;)3oyS{-G;(cl96n;J{owA)F!ol5zzsZZmDg z5Rtp$J*dL+ytU`tvpvTiZEmfqy1FE9bKcjiyhq4R-O(sFf$h^Q#$rcxV~MLP-fj81 zS~<)$CHu@fNaR)ZeN6*JUii#A*Js=b`((?>HS^B%l1~pS(-aEuYqa`R%l*b?ditAw&(m$(HXbasu#wkS8bJTb@Fax zzH7l>RHc>YywhcZ+T|l>3sstPn+~mbexYf_iEoDI_9~a?Y>|&X>e8gAXtRUoY!qfd zNc?Srl=#8hL1n_qBNCmGBA?y|2TaQ0`k?!D&)=}*!~nyGW(T{EnNITVG&IotvNe#Y z;`W{e3=d-!*tO3qsu~`j@Gg|!yml)8v)^hKRgNKx{MZhkI9if$EJ=e< zHtkL;Wh~_ygP31&+zOR!?ATIkwY*}Mfmz=}dG1dZzhr98uoN<^Qm|FYnfKz<*@tFo zpF6*rB4z)8Nf$gU`ZpD*u4U+Pgj5-;l~$bIadgf*KVBJKTPD`$oi7wkObP`52s0=% zJ$mE6%#kBc&%E|je&@}3O%I>TiKkh0cM08*(fciux}81AHgH0Hs~e+5H~+<;YU>?u z8YcK2b6o~W@dd008VuD$k4S#$`xv%7FKpgc1EJfU!g3w%lP2&7F)f{ZK=_g<^WC?% zLKzn`J@Wmd#6IN@)90CQM5k2n#C~&P*uk%=z$YOreWRyO&QakW*S4O|@+o!9g%&b< z`KL8pVydM(&+upwOONmJ*GU5G6AmqvJ67fvmCmvI+GfTDli#c@KHHr5g6YubnMbY_ z^p%zR>wZyDKfi+gb#0KW0HfUXPH`QNC%X3jdgd~HtP#&7lwUX*$*5FcOYjgDu3xlN z=5a~-X*b3*5p@$fmC`QTe+ZopDlz6T7JD!oOK5i(zZBjR3UbQ&i3b;VG73FD5P9a% zVumzN^+ih*3N~lgtvq^fS?H2A46?7y_S}A9_o20SL)Gk`{0e;ROe$(WWEjna#W=S} zt`HY4kh#oV>&S7?{n(v;=PJ9!0u?`5`vhv;3wqa>RmtzU-eLH<{n~QD7l}ba7Pt5A z4|U^snD}bx@{Y|jrhIts`~=c4V_^8wc`snP?Se@aosAP9xn>>bKfwu`&Z+%pSY=dk ztFiC_=S|~77BhbT71fYyD({`L1uz%7CY{Oc7rF5s^|e`WQFkMi|fDt(tYd^g;3 z_3zqWv(2WcsQrjzxmnmJEf~1*_@P?<^_e zre7(QoowU$pF!B(`(Ni0IfdSfO)I(E6mF;eX9(?$0wuE$#WR@-ePRdCC~@*K9NcmA zxYkzdqAg!ncy~0uPg=tJK-cD-??*Yazk0v4(zY3h&2#cvT>Rq(`!%n{+3Y)Q&anPy zck|s)t0)xnnPIXT>p%Z@g}2ax`GrGecd-D8#YZ zIP+-&*W7cGK73A0i;MdlgtwmRF-Y@}cT^3Y@7D2aW=QIhH2*6coD0{AGbr%qtLaJq zT$dpqoh7g`#+LJ4r@C>LkRR7AC)-)pKMTs^m6>N16ihs^N}uzA$1hLrk1PcR?5`9J zY|>=R3__)6)qi5iuUcNf%sKJIiC)3Fb8mWV zQGPU$<*r#&=wUmvMI5t}^dECx`qrt$_wVt^^dlbMx9GoP_$<0DN>%E^rrUvRR|fMm8?HVFa z#4^c2B}KbLK5ymP@{Q~-5(7HF8g4SraQ-5elMPHcJGMFeR{Ig3Cy|iw zW@U}bOtHx_pIv^;QMjEZyQk^eE!*{Sj{<+hCcdccpVVW0g{j3vqH#|2SAT|?8+sVK zFU~MnaYj0gr`T8Jm&kqQLbd-4iB$sZeENi;|XOZ2iV_Om@ob-|0cR01H`+#HP5%p#l z*Jgprs>e^|nb*pIDj=C->1Cb*~n|!8b$Ee-u%V4-;V<>lFUhJc99d~Y5E$T8fNcbIH zIP1$-?cj1z0TlstVR1#N(Qm02w;ms1@!*q)uUX~EYs?azdhOST&rzQXUR7wv zJXU$<&#+E>&N=mSjO@%APK<)7(_)iU8qM!#F+_hj=J6-#H&0~k9&K=MesQux<10q> zJB#&;9p^M|gES#@%l3jQfGRPK2UN&6DTQT{8$4!YF|ZUwhT|1&t~iVIdaMb3CV$Vf(U5H8bxnj9df9rC_EBD%4XZ1UFx&P1wg~SY&DXHf&>>6&%o&`JYS_1b;|NjhLjoWwKROb+%d_e!W^;6sHkLP~c zz3Wf$zVPz+zVcJ=UVl9IGxo0aakuwR>+2+&Gu#|kRIKDZP#WJ|tjW7{Ez2>d!werl zDy!EYw|-h)`r~@dx_A3!-cP*?G0BMk(|#s{tqiHGxr#eVZNwj)mdpLHMVOf(;pU6| zQgCH|iuVQIt+TwZ`EI||OLLC`;Ui}|Dl%E;$bL7y^(VHuc-w13?T_mwPAHT+7XEnd zr+FY}`Ir894!7(PuV(JLOco(S1<4r?Gu$}#BtavWFJrqBTef!@q!_vr?dX=o`p)dY z_vXwEEX)!2pJwdfTWN!~Blmq?%(K z|H8R%A9!|uMcBT+lM;&`GI-CO&}sOly>-T%Sc!R({Dw_>8b35=aL=*ddLYUthA)vr zEv1l+7g9MbdDFA?z|DzFDMb*6|7ZBk{_kp>kG}Mr+nfR{kA>6(mOQ(6V(0oM_b!D4 zlXS!+w(V6e*E#nv%lgWT#W_B4o;w(4p1A>56jYp_5+lcG@L~(28pjdtgzHP{Cq}iM zi!@IRRO}H-x|DK&ncGj5%P;p$tGd}YmjedQiyVy{CnmCKozef9b>VGmUZMWhg&Pe^ zCgp6o12^%yWuK7FmP9t4iCnFBrb=dfu)1WSbM6_WC17yv=nNqTqvCx`CDXat9KJ$4 zahGAo*@ioQ3CsJZ?wx%6)V)*JXR0Ms&6)7gD(Q6HLhIw+EDrV@6I_=I=N^K2=IYAb zmS!$TAE@yq#xb|6?b>S_vz522{;BNb_EYyxUgGii&}fhn!?@QWciSY^o2<#v)xEuP zKjtxA)njQFmsV+TZD!0fyE(AP z*k*J7dtNoKiPd4p><0HEe|gR8cRYNd>%hRlz{tR8#ya!GR@qlq?#|h4tM#AZ{GZsk z`G4nhGDuGR9@6)#421nkb=5f(svEP)dk;NhPWaDIyq)Pv;-Y8W3|~aKS)bfsUuniF zvZYzbPV5(tzi^U~2g^L;Lq`|h=6V^!u!G4VBtu!IVEr=rYdc%+NXSN#~a~(QZXw0oMxU9%#;2E{EWR>r&F`;%^aEQoS728 zr5z=j1AlnSDs!{WnU-D*8C|nzGt)UUfoINpH%~Ff23yGZPMHzuWGDFVV*g4%l{sEZJ~TG4On%4OXvJ_s?$jjD9gfni zGhb}BzM>{`saAKZ70={CYgUg+xf9-r&x%;rH5Kg%U%oPY$C0Orw&H)IYGNKLE^j!} zbGGy7kw;nOze*}+pa1Rp)ck;9;ns8C4-{~H^?XzoC+_eoYJU3f*qhczT)+2TYtPZ% zeegfS#x?Gr>!-&5&A*ve4r$#5HeJlHXxOqn3*6*i|DRz?{o82ihY}SoT3YrKkg;0Z zbO!$xhnW_vGw&!0J*w?JwwUd4hq96(SLb|%5TBn+ycyVF*z=6dzs z=}&cg=e}uix$@1x-4oxexO?JTNZylbE%PGV2~ARWPYJO!A4)O{=YLxIbPr$d!wO%O zI1ZL31BF&lo`k8KD!o(gRPUYs6y}v5#JBF-xx4kCr@~3wYwKDKkNCMOAPlWpj$)Hf z>0{>ki_W|Y>1?`W!Pu82?SI04^S;)=UGB^nu9>o_cl#vWFU&&0H6}?9JFVsv+B>vJ z`&3PG1sA~~9@$A}P715fGJ2%L%;5UG2b5!`N8O87IU{joReG`3%sZ!+l^d;`dj`%q z6WnQ}s}?XJU|N!)+#FHO3<;*hVC$J7=bnWc@0{vWIo0R=jMZ!^5AQ5w7Vfm1x_Ti@ z=3Oe1GBC%@lVMVAz=X=?8DU%QR6hwdUVLR$@);0<*jzoQfMxPY&NAKuRUaXPbvKSB>(65uFRkPU_&;%~d6O)1TNLHey8mGfRry>Iw# zrR-<9R}((lUe(^0d^gDY8I-a6neA1@XVt61YmV<)T7IT_mGhbJA?tqjmwJE-y}qCQ zrCwkLvJw=v?K9u4j72tDE`8S$oAVHDFiEfjK&lXoJLgM2IWOW=IViW?%PbT;u;EpfS-p!zWp(XuM8~hCl#P~w3q#iiVUN`Y8*VH|S7VGgb%n;7gsPK+a%W0@PYGcv; z;8aDX~eteRxUKd;+IVm1^q!+T5B|gK=@rYK~XO5e~ zHlo^rnzU!he!I& zS`$oW=NinB6+bxDL#jx{t3c@`$d&}J0%gOjr5Q>eJpB0B1y)9JJQAFcX92avIArk* zHwIw~r=>ab6fGDGHBRa^N<2^uoYvOlsKoO7X=cdUXM)qV9lZWJV%ip=w2LLIF88OD zg|s-k98|je@{s6~9o)IIE0!_%eqabmJd(!f%ge67cB*&DGY3@@lLCPiw_@^Dn1Y?% z92`P?RqlCkFt9OzSoamww{Fmzp!3D8|I@0lqZc2^UR?ZV)zl?2HqN_D68gWba+^?~ zBDkwmLg|r>Vvs>kCyTNhUqVBuvJ6{_$TQ{{x}0xHVpW%yd^5{$x@a?b#^af~ns0KR zM49<5E<7`B(n<+-CbpATQs#JOYQFuFDff-l?UL`4EkX?p2|8SzOJ={}aFAeix#!5O z*9!8Elj9~);gkZ|2`nqm0m|E4XIu$awm znrSw9<{JaQ=?_>xA6MwvF;gSrbnS=te{9Utf#*7V@#|(GUxnS)thoJ4O_}|H6L01@oVaDFz-U~~ESh=+Qr*F7=GE;#xi(3MTzLL~ zv++NJB8SDVWo;9D9A`=hA3r(a_AfhYd1dFhN{15M)RwjFy3IJTc4|u5YV-PutHE8m zEpw-|Z{g-sh*SdCnpqG3IjvPr$;-T>*(lCwwz`9*O2q$=hmq37Cns|37eC(k$;U&C zbIa8ZZas1D@~#VJtIhKu#nQs$2IU(Jbqg6(n39FXXMvj?AkS^-oz|Xr z#YbY-Z_xt`wo?uorJSD(4j4Pn2FDFYPd!rf@idzm<@eg*)*4uQpW%RtADceM;TsGJ zkHtLBfU3&nYh&zBgf7Y1B6~>rc#SQ=Z`;MPD^i#pX;f9*wuH>r*+b$`5IL^A{-4@fz^U&^PDA=# zZY5=olN`d@D8{&5Z$erq>c46(miZcD$oxQ>5R$v2#;M#R8vLi$$EX zmGr*-s)V{w!OG=K?E56;<6^0XTNZIIxhq~Bv1RVH#;uXNi^IURcn)}RfM*7DqG!|V z&}T{&0>K`)Z`|3^J9$~}S>;3dEE94px`nwB>#>mdJhUwtjz{c`jMjR90^)w{Fg1Nj!Snl768!YEpQhZYL_l7Gl z9MW6wAGFoHF!X}*C2Po}*-cRUD6(|T0kKJIvg^e9SK2K*HA{w%m4oTYg$q721mWrE z!owOtvxSN#CMjC>qKXV3Sl)$RefbL1pb?7VWEXkh9mOcoEYY=9_SEHT+bp&VF>ZPK zLV#uRG>xVgTe?|3e0~B>Lgy|$(qj--=iYFZNzT(=z;3!I z`-G`NfF+4F%|WHQnWb9ZvZ1~A=AA1p-8u)e@*XW>+0tkM9t~!xVscl~TB0z?lZ8q5 z$?QiNN21Tf2Y!_hN$5VNeA+<2!KbiMO;k}&#kX|>>-GtTj28bHRyeROT&I?FK_NP8 zqY!^b6sLsfk9HSkp_kk53I1q~QG3~tuzXS)qsoj4q1JOB&F(y8@iFA+f$k-* z%n_IN?BMIzuqs|ciD6RmtYx}QzDtgNYz*0F;Lz>amo@zm%Ykh=h72BCy;mGLs4k=A zb1>u?_Zo)TYJORBrp>NVncOIO=){{jK4SMg&zn4cI5}m;zu=}yh94xFod4%L!6r+=iEh7-xeGpBxN+m7Zk8jHbDKOv0{sr@i?z*J-myC1#;Rn-&1~vGK8aL}^5e~njp2dCsgrX2G_SIo5igE~rEA{-Alnf+0-TIeA#H8 z)WuGN6=xMCX0r!1OWAVToOBS%nV@_k;V=J+Fq=55KCa{29$)yN;mF3}{;+mlJM)E~ z4suGKk9?QRx$t9=y2PpF6}fCldGmd)+RmF_noywRyUe3g^PZ-0NvY;Pd7g*HhDTC1 zFcfiqel%N2$>+Xlu*;;pGrv~(i*)RIUF)?}N1Z>WL*Y<)=X2?X-&0;}k?h9`;HoN|ZZf$-zQVy++3e)@gbx#P%*(=6|W8=8t&F4)1r zE}(BX<+1nzjtRMEqWGD37oV!wz2H^AoC&ksMK33I#bBW*_7FE)=kF zdez^lOCs1(?g&n|IQhnchkb&?>__JfH-4+`-mU+R%{E^?s)<7ZI}+tk@%(D`mQlW~ z`_ + wave.amount_tickets > 0 && + wave.opening_date <= today && + wave.closing_date >= today + ) + }, + selectedTicketWave() { + return ( + this.activeTicketWaves.find( + wave => wave.id === this.formDialog.data.ticket_wave_id + ) || + this.activeTicketWaves[0] || + null + ) + }, + showTicketWaveSelector() { + return this.activeTicketWaves.length > 1 + }, allowFiatCheckout() { - return Boolean(this.event?.allow_fiat) + return Boolean(this.selectedTicketWave?.allow_fiat) }, fiatCheckoutLabel() { if (!this.allowFiatCheckout) return 'Fiat' const unit = ['sat', 'sats'].includes( - (this.event?.currency || '').toLowerCase() + (this.selectedTicketWave?.currency || '').toLowerCase() ) - ? this.event?.fiat_currency - : this.event?.currency + ? this.selectedTicketWave?.fiat_currency + : this.selectedTicketWave?.currency return `Fiat (${(unit || 'GBP').toUpperCase()})` }, allowEmailNotifications() { @@ -66,6 +88,16 @@ window.PageEventsDisplay = { 'GET', `/events/api/v1/events/${this.eventId}` ) + const activeWaves = (data.extra?.ticket_waves || []).filter(wave => { + const today = new Date().toISOString().slice(0, 10) + return ( + wave.amount_tickets > 0 && + wave.opening_date <= today && + wave.closing_date >= today + ) + }) + this.formDialog.data.ticket_wave_id = + activeWaves.length === 1 ? activeWaves[0].id : null return data } catch (error) { this.eventErrorLabel = 'Event unavailable.' @@ -78,6 +110,10 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.ticket_wave_id = + this.activeTicketWaves.length === 1 + ? this.activeTicketWaves[0].id + : null this.formDialog.data.payment_method = 'lightning' }, @@ -112,6 +148,10 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.ticket_wave_id = + this.activeTicketWaves.length === 1 + ? this.activeTicketWaves[0].id + : null this.formDialog.data.payment_method = 'lightning' Quasar.Notify.create({ type: 'positive', @@ -141,10 +181,13 @@ window.PageEventsDisplay = { { name: this.formDialog.data.name, email: this.formDialog.data.email, + ticket_wave_id: this.formDialog.data.ticket_wave_id || null, promo_code: this.formDialog.data.promo_code || null, refund_address: this.formDialog.data.refund || null, nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.formDialog.data.payment_method + payment_method: this.allowFiatCheckout + ? this.formDialog.data.payment_method + : 'lightning' } ) const isFiat = Boolean(data.is_fiat) @@ -178,7 +221,7 @@ window.PageEventsDisplay = { const url = new URL(window.location) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/api/v1/ws/${paymentHash}` + url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` url.search = '' url.hash = '' @@ -187,7 +230,7 @@ window.PageEventsDisplay = { ws.onmessage = event => { const data = JSON.parse(event.data) - if (data.pending === false) { + if (data.paid === true) { this.paymentSuccess(paymentHash) ws.close() } diff --git a/static/js/display.vue b/static/js/display.vue index f5ac5fb..9051fbf 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -49,7 +49,7 @@ filled dense v-model.trim="formDialog.data.nostr_identifier" - label="(optional) Nostr NIP-05 or npub" + label="(optional) Nostr NIP-05" hint="If provided, we'll DM your ticket link after payment." > @@ -64,6 +64,21 @@ lazy-rules :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" > +

this.shortenId(row.id) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, { name: 'event_start_date', @@ -22,12 +30,6 @@ window.PageEvents = { label: 'End date', field: 'event_end_date' }, - { - name: 'closing_date', - align: 'left', - label: 'Ticket close', - field: 'closing_date' - }, { name: 'canceled', align: 'left', @@ -38,43 +40,21 @@ window.PageEvents = { } return 'No' } - }, - { - name: 'price_per_ticket', - align: 'left', - label: 'Price', - field: row => { - if (this.isFiatCurrency(row.currency)) { - return LNbits.utils.formatCurrency( - row.price_per_ticket.toFixed(2), - row.currency - ) - } - return row.price_per_ticket - } - }, - { - name: 'amount_tickets', - align: 'left', - label: 'No tickets', - field: 'amount_tickets' - }, - { - name: 'sold', - align: 'left', - label: 'Sold', - field: 'sold' - }, - {name: 'info', align: 'left', label: 'Info', field: 'info'}, - {name: 'banner', align: 'left', label: 'Banner', field: 'banner'} + } ], pagination: { rowsPerPage: 10 } }, ticketsTable: { + loading: false, columns: [ - {name: 'event', align: 'left', label: 'Event', field: 'event'}, + { + name: 'event', + align: 'left', + label: 'Event', + field: row => this.shortenId(row.event) + }, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, { @@ -83,6 +63,12 @@ window.PageEvents = { label: 'Registered', field: 'registered' }, + { + name: 'nostr', + align: 'left', + label: 'Nostr', + field: row => row.extra?.nostr_identifier || '' + }, { name: 'promo_code', align: 'left', @@ -92,7 +78,11 @@ window.PageEvents = { {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { - rowsPerPage: 10 + sortBy: 'time', + descending: true, + page: 1, + rowsPerPage: 10, + rowsNumber: 10 } }, formDialog: { @@ -102,28 +92,198 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { + ticket_waves: [], promo_codes: [], notification_subject: '', notification_body: '' } } + }, + ticketWaveDialog: { + show: false, + eventId: null, + wallet: null, + editingWaveId: null, + data: { + id: null, + title: '', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + }, + promoCodesDialog: { + show: false, + data: { + id: null, + wallet: null, + name: '', + extra: { + promo_codes: [] + } + } } } }, methods: { + shortenId(value) { + if (!value) return '' + return value.length > 4 ? `${value.slice(0, 4)}...` : value + }, + primaryTicketWave(data = this.formDialog.data) { + if (!data.extra) data.extra = {} + if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { + data.extra.ticket_waves = [ + { + id: 'primary', + title: 'Primary wave', + opening_date: data.closing_date || '', + closing_date: data.closing_date || '', + currency: data.currency || 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: Boolean(data.allow_fiat), + fiat_currency: data.fiat_currency || 'GBP', + amount_tickets: data.amount_tickets || 0, + price_per_ticket: data.price_per_ticket || 0 + } + ] + } + return data.extra.ticket_waves[0] + }, + syncPrimaryWaveFromForm(data = this.formDialog.data) { + const primaryWave = this.primaryTicketWave(data) + primaryWave.title = primaryWave.title || 'Primary wave' + primaryWave.opening_date = primaryWave.opening_date || '' + primaryWave.closing_date = data.closing_date || '' + primaryWave.currency = data.currency || 'sats' + primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image) + primaryWave.ticket_image_id = primaryWave.ticket_image_id || null + primaryWave.allow_fiat = Boolean(data.allow_fiat) + primaryWave.fiat_currency = data.fiat_currency || 'GBP' + primaryWave.amount_tickets = Number(data.amount_tickets || 0) + primaryWave.price_per_ticket = Number(data.price_per_ticket || 0) + return primaryWave + }, + hydrateEventForm(data) { + const formData = { + ...data, + extra: { + ...(data.extra || {}), + ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])] + } + } + const primaryWave = this.primaryTicketWave(formData) + formData.currency = primaryWave.currency || formData.currency || 'sats' + formData.allow_fiat = Boolean(primaryWave.allow_fiat) + formData.fiat_currency = primaryWave.fiat_currency || 'GBP' + formData.amount_tickets = primaryWave.amount_tickets + formData.price_per_ticket = primaryWave.price_per_ticket + formData.closing_date = + primaryWave.closing_date || formData.closing_date || '' + return formData + }, isFiatCurrency(currency) { return !['sat', 'sats'].includes((currency || '').toLowerCase()) }, - getTickets() { - LNbits.api - .request( + normalizePromoCodes(promoCodes = []) { + return promoCodes + .filter(code => code.code?.trim() !== '') + .map(code => ({ + ...code, + code: code.code.trim().toUpperCase() + })) + }, + templateDownloadUrl() { + return '/events/static/image/ticket.jpg' + }, + async uploadAssetFile(file) { + const form = new FormData() + form.append('file', file) + form.append('public_asset', 'true') + const {data} = await LNbits.api.request( + 'POST', + '/api/v1/assets?public_asset=true', + null, + form + ) + return data.id + }, + triggerTicketImageUpload(target) { + this.ticketImageUploadTarget = target + this.$refs.ticketImageUpload.value = null + this.$refs.ticketImageUpload.click() + }, + async handleTicketImageSelected(event) { + const file = event.target.files?.[0] + if (!file || !this.ticketImageUploadTarget) return + + this.isUploadingTicketTemplate = true + try { + const assetId = await this.uploadAssetFile(file) + if (this.ticketImageUploadTarget === 'primary') { + const wave = this.primaryTicketWave() + wave.use_ticket_image = true + wave.ticket_image_id = assetId + } else if (this.ticketImageUploadTarget === 'dialog') { + this.ticketWaveDialog.data.use_ticket_image = true + this.ticketWaveDialog.data.ticket_image_id = assetId + } + Quasar.Notify.create({ + type: 'positive', + message: 'Ticket template uploaded.', + icon: null + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.isUploadingTicketTemplate = false + this.ticketImageUploadTarget = null + } + }, + soldTicketsForWave(eventId, waveId) { + return this.allPaidTickets.filter( + ticket => + ticket.event === eventId && + ticket.paid && + (ticket.extra?.ticket_wave_id === waveId || + (!ticket.extra?.ticket_wave_id && waveId === 'primary')) + ).length + }, + async getAllTickets() { + try { + const {data} = await LNbits.api.request( 'GET', '/events/api/v1/tickets?all_wallets=true', this.g.user.wallets[0].adminkey ) - .then(response => { - this.tickets = response.data.filter(e => e.paid) - }) + this.allPaidTickets = data.filter(ticket => ticket.paid) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async getTickets(props) { + try { + this.ticketsTable.loading = true + const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props) + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/tickets/paginated?all_wallets=true&${params}`, + this.g.user.wallets[0].adminkey + ) + this.tickets = data.data + this.ticketsTable.pagination.rowsNumber = data.total + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.ticketsTable.loading = false + } }, deleteTicket(ticketId) { const tickets = _.findWhere(this.tickets, {id: ticketId}) @@ -138,10 +298,9 @@ window.PageEvents = { '/events/api/v1/tickets/' + ticketId, wallet.adminkey ) - .then(response => { - this.tickets = _.reject(this.tickets, function (obj) { - return obj.id == ticketId - }) + .then(async () => { + await this.getTickets() + await this.getAllTickets() }) .catch(LNbits.utils.notifyApiError) }) @@ -159,14 +318,30 @@ window.PageEvents = { wallet.adminkey ) .then(response => { + const result = response.data this.tickets = this.tickets.map(obj => - obj.id === ticket.id ? response.data : obj + obj.id === ticket.id ? result.ticket : obj ) - Quasar.Notify.create({ - type: 'positive', - message: 'Ticket email resent.', - icon: null - }) + + if (result.email?.attempted) { + Quasar.Notify.create({ + type: result.email.sent ? 'positive' : 'negative', + message: result.email.sent + ? 'Ticket email resent.' + : `Ticket email failed: ${result.email.error || 'Unknown error.'}`, + icon: null + }) + } + + if (result.nostr?.attempted) { + Quasar.Notify.create({ + type: result.nostr.sent ? 'positive' : 'negative', + message: result.nostr.sent + ? 'Ticket Nostr DM resent.' + : `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`, + icon: null + }) + } }) .catch(LNbits.utils.notifyApiError) .finally(() => { @@ -176,7 +351,7 @@ window.PageEvents = { }) }, exportticketsCSV() { - LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) + LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) }, getEvents() { LNbits.api @@ -195,19 +370,18 @@ window.PageEvents = { id: this.formDialog.data.wallet }) const data = this.formDialog.data + this.syncPrimaryWaveFromForm(data) if (data.extra?.promo_codes) { - data.extra.promo_codes = data.extra.promo_codes - .filter(code => code.code?.trim() !== '') - .map(code => ({ - ...code, - code: code.code.trim().toUpperCase() - })) + data.extra.promo_codes = this.normalizePromoCodes( + data.extra.promo_codes + ) } if (!this.isFiatCurrency(data.currency)) { if (!data.allow_fiat) { data.fiat_currency = 'GBP' } } + this.syncPrimaryWaveFromForm(data) if (data.id) { this.updateEvent(wallet, data) @@ -218,7 +392,7 @@ window.PageEvents = { openEventDialog(data = false) { if (data && data.id) { - this.formDialog.data = {...data} + this.formDialog.data = this.hydrateEventForm(data) } else { this.formDialog.data = { currency: 'sats', @@ -229,6 +403,21 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + ticket_waves: [ + { + id: 'primary', + title: 'Primary wave', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + ], promo_codes: [], notification_subject: '', notification_body: '' @@ -244,8 +433,25 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { + conditional: false, + min_tickets: 1, email_notifications: false, nostr_notifications: false, + ticket_waves: [ + { + id: 'primary', + title: 'Primary wave', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + ], promo_codes: [], notification_subject: '', notification_body: '' @@ -266,6 +472,177 @@ window.PageEvents = { const link = _.findWhere(this.events, {id: formId}) this.openEventDialog(link) }, + openTicketWaveDialog(event, wave = null) { + const primaryWave = (event.extra?.ticket_waves || [])[0] || {} + const isEditing = Boolean(wave) + this.ticketWaveDialog = { + show: true, + eventId: event.id, + wallet: event.wallet, + editingWaveId: wave?.id || null, + data: { + id: wave?.id || null, + title: wave?.title || '', + opening_date: wave?.opening_date || '', + closing_date: wave?.closing_date || '', + currency: + wave?.currency || primaryWave.currency || event.currency || 'sats', + use_ticket_image: Boolean(wave?.use_ticket_image), + ticket_image_id: wave?.ticket_image_id || null, + allow_fiat: isEditing + ? Boolean(wave?.allow_fiat) + : Boolean(primaryWave.allow_fiat ?? event.allow_fiat), + fiat_currency: + wave?.fiat_currency || + primaryWave.fiat_currency || + event.fiat_currency || + 'GBP', + amount_tickets: wave?.amount_tickets || 0, + price_per_ticket: + wave?.price_per_ticket || + primaryWave.price_per_ticket || + event.price_per_ticket || + 0 + } + } + }, + resetTicketWaveDialog() { + this.ticketWaveDialog = { + show: false, + eventId: null, + wallet: null, + editingWaveId: null, + data: { + id: null, + title: '', + opening_date: '', + closing_date: '', + currency: 'sats', + use_ticket_image: false, + ticket_image_id: null, + allow_fiat: false, + fiat_currency: 'GBP', + amount_tickets: 0, + price_per_ticket: 0 + } + } + }, + saveTicketWave() { + const event = _.findWhere(this.events, { + id: this.ticketWaveDialog.eventId + }) + const wallet = _.findWhere(this.g.user.wallets, { + id: this.ticketWaveDialog.wallet + }) + if (!event || !wallet) return + + const payload = { + ...event, + extra: { + ...event.extra, + ticket_waves: (event.extra?.ticket_waves || []).map(existingWave => + existingWave.id === this.ticketWaveDialog.editingWaveId + ? {...this.ticketWaveDialog.data} + : existingWave + ) + } + } + + if (!this.ticketWaveDialog.editingWaveId) { + payload.extra.ticket_waves.push({...this.ticketWaveDialog.data}) + } + + if (payload.extra?.promo_codes) { + payload.extra.promo_codes = this.normalizePromoCodes( + payload.extra.promo_codes + ) + } + + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + payload.id, + wallet.adminkey, + payload + ) + .then(response => { + this.events = this.events.map(item => + item.id === payload.id ? response.data : item + ) + Quasar.Notify.create({ + type: 'positive', + message: this.ticketWaveDialog.editingWaveId + ? 'Ticket wave updated.' + : 'Ticket wave added.', + icon: null + }) + this.resetTicketWaveDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, + openPromoCodesDialog(event) { + this.promoCodesDialog.data = { + ...event, + extra: { + ...event.extra, + promo_codes: [...(event.extra?.promo_codes || [])] + } + } + this.promoCodesDialog.show = true + }, + resetPromoCodesDialog() { + this.promoCodesDialog.show = false + this.promoCodesDialog.data = { + id: null, + wallet: null, + name: '', + extra: { + promo_codes: [] + } + } + }, + addPromoCodeToDialog() { + this.promoCodesDialog.data.extra.promo_codes.push({ + code: '', + discount_percent: 0, + active: true + }) + }, + savePromoCodes() { + const data = this.promoCodesDialog.data + const wallet = _.findWhere(this.g.user.wallets, { + id: data.wallet + }) + if (!wallet) return + + const payload = { + ...data, + extra: { + ...data.extra, + promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || []) + } + } + + LNbits.api + .request( + 'PUT', + '/events/api/v1/events/' + data.id, + wallet.adminkey, + payload + ) + .then(response => { + this.events = this.events.map(event => + event.id === data.id ? response.data : event + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Promo codes updated.', + icon: null + }) + this.resetPromoCodesDialog() + }) + .catch(LNbits.utils.notifyApiError) + }, updateEvent(wallet, data) { LNbits.api .request( @@ -332,6 +709,7 @@ window.PageEvents = { async created() { if (this.g.user.wallets.length) { this.getTickets() + this.getAllTickets() this.getEvents() if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { this.currencies = ['sats', ...this.g.allowedCurrencies] diff --git a/static/js/index.vue b/static/js/index.vue index fa39a4e..b90e922 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -100,45 +100,102 @@
-
Promo codes
+
+
Ticket waves
+ +
+
+
+
+ + + +
+
+
+ +
+
Promo codes
+ +
- No promo codes for this event. + No active promo codes for this event.
-
-
+
+
- - +
-
- Discount: - % -
-
- Status: - -
@@ -165,9 +222,11 @@ dense flat :rows="tickets" + :loading="ticketsTable.loading" row-key="id" :columns="ticketsTable.columns" v-model:pagination="ticketsTable.pagination" + @request="getTickets" > diff --git a/static/js/ticket.js b/static/js/ticket.js index 82fbd6d..6ff55b6 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -3,16 +3,36 @@ window.PageEventsTicket = { data() { return { ticketId: null, - ticket: null + ticket: null, + printMode: false, + qrSrc: '' } }, methods: { - printWindow() { - window.print() + async printWindow() { + this.printMode = true + await this.$nextTick() + await this.waitForPrintAssets() + setTimeout(() => window.print(), 50) + }, + async waitForPrintAssets() { + await this.$nextTick() + const img = document.querySelector('.ticket-print-qr') + if (!img) return + if (img.complete && img.naturalWidth > 0) return + await new Promise(resolve => { + const done = () => resolve() + img.addEventListener('load', done, {once: true}) + img.addEventListener('error', done, {once: true}) + setTimeout(done, 500) + }) } }, async created() { this.ticketId = this.$route.params.id + this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent( + `ticket://${this.ticketId}` + )}` try { const {data} = await LNbits.api.request( 'GET', @@ -22,5 +42,8 @@ window.PageEventsTicket = { } catch (error) { LNbits.utils.notifyApiError(error) } + window.addEventListener('afterprint', () => { + this.printMode = false + }) } } diff --git a/static/js/ticket.vue b/static/js/ticket.vue index 3c932e1..23a8dc4 100644 --- a/static/js/ticket.vue +++ b/static/js/ticket.vue @@ -36,4 +36,53 @@
+ + +
+ Ticket QR +
+
+ + diff --git a/views_api.py b/views_api.py index abdc1ef..49b8ec9 100644 --- a/views_api.py +++ b/views_api.py @@ -1,8 +1,11 @@ import asyncio from datetime import datetime, timezone from http import HTTPStatus +from io import BytesIO +from pathlib import Path from typing import Any +import pyqrcode # type: ignore[import-untyped] from fastapi import ( APIRouter, Depends, @@ -12,22 +15,27 @@ from fastapi import ( WebSocket, WebSocketDisconnect, ) +from fastapi.responses import StreamingResponse from lnbits.core.crud import get_user +from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import WalletTypeInfo from lnbits.core.models.payments import CreateInvoice from lnbits.core.services import create_payment_request +from lnbits.db import Filters, Page from lnbits.decorators import ( + parse_filters, require_admin_key, require_invoice_key, ) +from lnbits.helpers import generate_filter_params_openapi from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, satoshis_amount_as_fiat, ) -from lnbits.utils.nostr import normalize_public_key +from PIL import Image, ImageDraw from .crud import ( create_event, @@ -39,6 +47,7 @@ from .crud import ( get_events, get_ticket, get_tickets, + get_tickets_paginated, purge_unpaid_tickets, update_event, update_ticket, @@ -50,19 +59,53 @@ from .models import ( PublicEvent, PublicTicket, Ticket, + TicketFilters, TicketPaymentRequest, + TicketResendResult, + ensure_ticket_waves, + get_active_ticket_waves, ) from .services import refund_tickets, resend_ticket_email_notification from .tasks import deregister_payment_listener, register_payment_listener events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") +qr_api_router = APIRouter(prefix="/api/v1") +tickets_filters = parse_filters(TicketFilters) def _is_fiat_currency(currency: str | None) -> bool: return str(currency or "").lower() not in {"sat", "sats"} +def make_qr_png(data: str, size: int = 235, border: int = 4) -> Image.Image: + qr = pyqrcode.create(data) + matrix = qr.code + modules = len(matrix) + + total_modules = modules + border * 2 + box_size = max(1, size // total_modules) + img_size = total_modules * box_size + + img = Image.new("RGBA", (img_size, img_size), "white") + draw = ImageDraw.Draw(img) + + for y, row in enumerate(matrix): + for x, cell in enumerate(row): + if cell: + x0 = (x + border) * box_size + y0 = (y + border) * box_size + draw.rectangle( + [x0, y0, x0 + box_size - 1, y0 + box_size - 1], + fill="black", + ) + + if img_size != size: + img = img.resize((size, size), Image.Resampling.NEAREST) + + return img + + @events_api_router.get("") async def api_events( all_wallets: bool = Query(False), @@ -86,24 +129,29 @@ async def api_get_event(event_id: str) -> Event: ) await purge_unpaid_tickets(event_id) - is_window_open = datetime.now(timezone.utc) < datetime.strptime( - event.closing_date, "%Y-%m-%d" - ).replace(tzinfo=timezone.utc) + today = datetime.now(timezone.utc).date() + active_waves = get_active_ticket_waves(event, today) + is_sales_closed = today > datetime.strptime(event.closing_date, "%Y-%m-%d").date() is_min_tickets_met = ( event.sold >= event.extra.min_tickets if event.extra.conditional else True ) if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") - if event.extra.conditional and not is_min_tickets_met and not is_window_open: + if event.extra.conditional and not is_min_tickets_met and is_sales_closed: event.canceled = True await update_event(event) await refund_tickets(event_id) raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") - if not is_window_open: + if not active_waves: raise HTTPException( - status_code=HTTPStatus.GONE, detail="Ticket closing date has passed." + status_code=HTTPStatus.GONE, + detail=( + "Ticket closing date has passed." + if is_sales_closed + else "No ticket wave is currently open." + ), ) return event @@ -127,8 +175,17 @@ async def api_event_create( raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Not your event." ) - for k, v in data.dict().items(): - setattr(event, k, v) + event = Event( + **{ + **event.dict(), + **data.dict(), + "id": event.id, + "wallet": event.wallet, + "time": event.time, + "sold": event.sold, + "canceled": event.canceled, + } + ) event = await update_event(event) else: event = await create_event(data) @@ -187,6 +244,31 @@ async def api_tickets( return await get_tickets(wallet_ids) +@tickets_api_router.get( + "/paginated", + summary="Get paginated list of tickets", + openapi_extra=generate_filter_params_openapi(TicketFilters), + response_model=Page[Ticket], +) +async def api_tickets_paginated( + all_wallets: bool = Query(False), + filters: Filters = Depends(tickets_filters), + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> Page[Ticket]: + wallet_ids = [key_info.wallet.id] + + if all_wallets: + user = await get_user(key_info.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + if not filters.sortby: + filters.sortby = "time" + if not filters.direction: + filters.direction = "desc" + + return await get_tickets_paginated(wallet_ids, filters) + + @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) async def api_get_ticket(ticket_id: str) -> Ticket: ticket = await get_ticket(ticket_id) @@ -202,6 +284,71 @@ async def api_get_ticket(ticket_id: str) -> Ticket: return ticket +@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) +async def api_ticket_qr(ticket_id: str): + ticket = await get_ticket(ticket_id) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + + event = await get_event(ticket.event) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + + waves = ensure_ticket_waves(event) + wave = next( + (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), + waves[0], + ) + + qr_img = make_qr_png(f"ticket://{ticket_id}", size=157) + output = BytesIO() + + if not wave.use_ticket_image: + qr_img.save(output, format="PNG") + output.seek(0) + return StreamingResponse( + output, + media_type="image/png", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + background_bytes = None + if wave.ticket_image_id: + asset = await get_public_asset(wave.ticket_image_id) + if asset: + background_bytes = asset.data + + if background_bytes: + ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA") + else: + default_template = ( + Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg" + ) + ticket_image = Image.open(default_template).convert("RGBA") + + ticket_image.paste(qr_img, (122, 505)) + ticket_image.save(output, format="PNG") + output.seek(0) + + return StreamingResponse( + output, + media_type="image/png", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + @tickets_api_router.post("/{event_id}") async def api_ticket_create( event_id: str, data: CreateTicket, request: Request @@ -215,7 +362,7 @@ async def api_ticket_create( if event.canceled: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") - if event.amount_tickets > 0 and event.sold >= event.amount_tickets: + if event.amount_tickets < 1: raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") name = data.name @@ -230,14 +377,36 @@ async def api_ticket_create( detail="Unsupported payment method.", ) if nostr_identifier and "@" not in nostr_identifier: - try: - nostr_identifier = normalize_public_key(nostr_identifier) - except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Only NIP-05 Nostr identifiers are supported.", + ) + active_waves = get_active_ticket_waves(event) + if not active_waves: + raise HTTPException( + status_code=HTTPStatus.GONE, detail="No ticket wave is currently open." + ) + + selected_wave = None + if data.ticket_wave_id: + selected_wave = next( + (wave for wave in active_waves if wave.id == data.ticket_wave_id), + None, + ) + if not selected_wave: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="Invalid Nostr identifier.", - ) from exc - price = event.price_per_ticket + detail="Invalid ticket wave selected.", + ) + elif len(active_waves) == 1: + selected_wave = active_waves[0] + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Please select a ticket wave.", + ) + + price = selected_wave.price_per_ticket extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} if promo_code: @@ -249,31 +418,31 @@ async def api_ticket_create( # get the promocode promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) extra["promo_code"] = promo.code - price = event.price_per_ticket * (1 - promo.discount_percent / 100) + price = selected_wave.price_per_ticket * (1 - promo.discount_percent / 100) - if payment_method == "fiat" and not event.allow_fiat: + if payment_method == "fiat" and not selected_wave.allow_fiat: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Fiat payments are not enabled for this event.", ) - if _is_fiat_currency(event.currency): + if _is_fiat_currency(selected_wave.currency): extra["fiat"] = True - extra["currency"] = event.currency + extra["currency"] = selected_wave.currency extra["fiatAmount"] = price - extra["rate"] = await get_fiat_rate_satoshis(event.currency) + extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency) if payment_method != "fiat": - price = await fiat_amount_as_satoshis(price, event.currency) + price = await fiat_amount_as_satoshis(price, selected_wave.currency) - invoice_unit = event.currency + invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None if payment_method == "fiat": - if _is_fiat_currency(event.currency): - invoice_unit = event.currency + if _is_fiat_currency(selected_wave.currency): + invoice_unit = selected_wave.currency else: - invoice_unit = event.fiat_currency + invoice_unit = selected_wave.fiat_currency fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) extra["fiat"] = True extra["currency"] = invoice_unit @@ -314,6 +483,8 @@ async def api_ticket_create( email=email, extra={ "applied_promo_code": promo_code, + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": str(request.base_url).rstrip("/"), @@ -388,10 +559,12 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.post("/{ticket_id}/resend-email") +@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( - ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -) -> Ticket: + ticket_id: str, + request: Request, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> TicketResendResult: ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( @@ -408,16 +581,13 @@ async def api_ticket_resend_email( ) try: - return await resend_ticket_email_notification(ticket) + return await resend_ticket_email_notification( + ticket, str(request.base_url).rstrip("/") + ) except ValueError as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) ) from exc - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to resend ticket email.", - ) from exc @tickets_api_router.put("/register/{ticket_id}") From 8c1538f5fcdb058542095c338fdd10a4c9abdc63 Mon Sep 17 00:00:00 2001 From: Arc Date: Fri, 22 May 2026 20:30:24 +0100 Subject: [PATCH 02/37] fix: no submit bug --- static/js/display.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/static/js/display.js b/static/js/display.js index 6b53d9d..498cd2c 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -126,6 +126,13 @@ window.PageEventsDisplay = { this.paymentWebsocket.close() this.paymentWebsocket = null } + this.paymentReq = null + this.receive = { + show: false, + status: 'pending', + paymentReq: null, + isFiat: false + } }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g From 35c20eda7283310385cef8022283c6f9975e0de2 Mon Sep 17 00:00:00 2001 From: Arc Date: Fri, 22 May 2026 20:36:30 +0100 Subject: [PATCH 03/37] fix: another hot one --- static/js/display.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/display.vue b/static/js/display.vue index 9051fbf..382f25a 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -110,7 +110,7 @@ formDialog.data.name == '' || formDialog.data.email == '' || (showTicketWaveSelector && !formDialog.data.ticket_wave_id) || - Boolean(paymentReq) + (receive.show && Boolean(paymentReq)) " type="submit" >Submit Date: Wed, 3 Jun 2026 10:51:15 +0200 Subject: [PATCH 04/37] feat: onchain payments (#53) * feat: onchain payments --- models.py | 6 ++ services.py | 43 ++++++++++ static/js/display.js | 59 +++++++++++-- static/js/display.vue | 63 +++++++++++--- static/js/index.js | 49 ++++++++++- static/js/index.vue | 48 +++++++++++ views_api.py | 188 ++++++++++++++++++++++++++++++++++++++---- 7 files changed, 423 insertions(+), 33 deletions(-) diff --git a/models.py b/models.py index 331ad6d..a16f7dd 100644 --- a/models.py +++ b/models.py @@ -45,6 +45,8 @@ class EventExtra(BaseModel): nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" + onchain_enabled: bool = False + onchain_wallet_id: str | None = None class CreateEvent(BaseModel): @@ -110,6 +112,7 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False + onchain: bool = False class CreateTicket(BaseModel): @@ -167,6 +170,9 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False + onchain_address: str | None = None + onchain_mempool_endpoint: str | None = None + onchain_amount_sat: int | None = None class TicketFilters(FilterModel): diff --git a/services.py b/services.py index 057026b..84c16e0 100644 --- a/services.py +++ b/services.py @@ -5,7 +5,9 @@ from asyncio.tasks import create_task from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from html import escape +from typing import Any +import httpx from lnbits.core.models.users import UserNotifications from lnbits.core.services.notifications import send_user_notification from lnbits.helpers import is_valid_email_address @@ -29,6 +31,47 @@ from .models import ( ) +async def fetch_watchonly_config(api_key: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet", + headers={"X-API-KEY": api_key}, + params={"network": network}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/static/js/display.js b/static/js/display.js index 498cd2c..a5cc160 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -27,7 +27,11 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null }, paymentDismissMsg: null, paymentWebsocket: null @@ -79,6 +83,32 @@ window.PageEventsDisplay = { }, allowNostrNotifications() { return Boolean(this.event?.extra?.nostr_notifications) + }, + allowOnchain() { + return Boolean(this.event?.extra?.onchain_enabled) + }, + showPaymentMethodSelector() { + return this.allowFiatCheckout || this.allowOnchain + }, + paymentMethodOptions() { + const options = [{label: 'Lightning', value: 'lightning'}] + if (this.allowFiatCheckout) { + options.push({label: this.fiatCheckoutLabel, value: 'fiat'}) + } + if (this.allowOnchain) { + options.push({label: 'Bitcoin', value: 'onchain'}) + } + return options + }, + onchainPaymentUri() { + if (!this.receive.onchainAddress) return '' + const btc = (this.receive.onchainAmountSat / 100000000).toFixed(8) + return `bitcoin:${this.receive.onchainAddress}?amount=${btc}` + }, + mempoolAddressUrl() { + if (!this.receive.onchainAddress || !this.receive.mempoolEndpoint) + return null + return `${this.receive.mempoolEndpoint}/address/${this.receive.onchainAddress}` } }, methods: { @@ -131,7 +161,11 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null } }, nameValidation(val) { @@ -169,7 +203,11 @@ window.PageEventsDisplay = { show: false, status: 'complete', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null } this.ticketLink = { show: true, @@ -192,15 +230,18 @@ window.PageEventsDisplay = { promo_code: this.formDialog.data.promo_code || null, refund_address: this.formDialog.data.refund || null, nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.allowFiatCheckout + payment_method: this.showPaymentMethodSelector ? this.formDialog.data.payment_method : 'lightning' } ) - const isFiat = Boolean(data.is_fiat) + const isOnchain = Boolean(data.onchain_address) + const isFiat = !isOnchain && Boolean(data.is_fiat) this.paymentReq = isFiat ? data.fiat_payment_request || null - : data.payment_request + : isOnchain + ? null + : data.payment_request this.paymentHash = data.payment_hash this.paymentDismissMsg = Quasar.Notify.create({ @@ -211,7 +252,11 @@ window.PageEventsDisplay = { show: true, status: 'pending', paymentReq: this.paymentReq, - isFiat + isFiat, + isOnchain, + onchainAddress: data.onchain_address || null, + onchainAmountSat: data.onchain_amount_sat || 0, + mempoolEndpoint: data.onchain_mempool_endpoint || null } if (isFiat && this.paymentReq) { window.open(this.paymentReq, '_blank', 'noopener') diff --git a/static/js/display.vue b/static/js/display.vue index 382f25a..d1ab493 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -80,20 +80,18 @@ " >
-
+
-
+
@@ -175,6 +173,51 @@ Close
+ +
+
Pay with Bitcoin
+ +
+ {{ + (receive.onchainAmountSat / 100000000).toFixed(8) + }} + BTC +
+
+ {{ receive.onchainAddress }} +
+
+
+ Copy address + View on mempool + Close +
+
4 ? `${value.slice(0, 4)}...` : value }, + async loadOnchainWallets() { + const wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + if (!wallet) return + try { + const {data} = await LNbits.api.request( + 'GET', + '/events/api/v1/events/onchain/status', + wallet.adminkey + ) + this.onchainWallets = data.available ? data.wallets || [] : [] + } catch { + this.onchainWallets = [] + } + }, + async confirmOnchainTicket(ticket) { + const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) + if (!wallet) return + try { + await LNbits.api.request( + 'PUT', + `/events/api/v1/tickets/${ticket.id}/onchain-confirm`, + wallet.adminkey + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Onchain payment confirmed.', + icon: null + }) + await this.getTickets() + await this.getAllTickets() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, primaryTicketWave(data = this.formDialog.data) { if (!data.extra) data.extra = {} if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { @@ -403,6 +440,8 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + onchain_enabled: false, + onchain_wallet_id: null, ticket_waves: [ { id: 'primary', @@ -425,6 +464,12 @@ window.PageEvents = { } } this.formDialog.show = true + if ( + this.formDialog.data.wallet && + this.formDialog.data.extra?.onchain_enabled + ) { + this.loadOnchainWallets() + } }, resetEventDialog() { this.formDialog.show = false @@ -437,6 +482,8 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + onchain_enabled: false, + onchain_wallet_id: null, ticket_waves: [ { id: 'primary', diff --git a/static/js/index.vue b/static/js/index.vue index b90e922..0d8ba76 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -265,6 +265,18 @@ > Resend ticket email + + Confirm onchain payment + @@ -602,6 +614,42 @@
+ +
+
+ Accept Bitcoin onchain payments. Requires the Watchonly + extension. +
+ + +
+
+
dict[str, Any]: + try: + config = await fetch_watchonly_config(wallet.inkey) + network = config.get("network") + if not network: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly extension returned an invalid network.", + ) + wallets = await fetch_watchonly_wallets(wallet.inkey, network) + except HTTPException: + raise + except Exception as exc: + return { + "available": False, + "message": f"Watchonly extension is not reachable: {exc!s}", + "network": None, + "wallets": [], + "mempool_endpoint": None, + } + return { + "available": True, + "message": None, + "network": network, + "wallets": wallets, + "mempool_endpoint": config.get("mempool_endpoint"), + } + + +async def _validate_watchonly_settings( + *, + wallet, + onchain_enabled: bool, + onchain_wallet_id: str | None, +) -> dict[str, Any]: + if not onchain_enabled: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Onchain payments are not enabled for this event.", + ) + if not onchain_wallet_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No watchonly wallet configured for onchain payments.", + ) + status = await _get_watchonly_status(wallet) + if not status["available"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=status["message"] or "Watchonly extension is not available.", + ) + try: + watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Cannot access watchonly wallet: {exc!s}", + ) from exc + if watch_wallet.get("network") != status["network"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly wallet network does not match user watchonly config.", + ) + return { + "watch_wallet": watch_wallet, + "network": status["network"], + "mempool_endpoint": status["mempool_endpoint"], + } + + def _is_fiat_currency(currency: str | None) -> bool: return str(currency or "").lower() not in {"sat", "sats"} @@ -120,6 +198,13 @@ async def api_events( return await get_events(wallet_ids) +@events_api_router.get("/onchain/status") +async def api_onchain_status( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> dict[str, Any]: + return await _get_watchonly_status(wallet.wallet) + + @events_api_router.get("/{event_id}", response_model=PublicEvent) async def api_get_event(event_id: str) -> Event: event = await get_event(event_id) @@ -371,7 +456,7 @@ async def api_ticket_create( refund_address = data.refund_address nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None payment_method = (data.payment_method or "lightning").lower() - if payment_method not in {"lightning", "fiat"}: + if payment_method not in {"lightning", "fiat", "onchain"}: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Unsupported payment method.", @@ -438,6 +523,10 @@ async def api_ticket_create( invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None + onchain_address = None + onchain_mempool_endpoint = None + onchain_amount_sat = None + if payment_method == "fiat": if _is_fiat_currency(selected_wave.currency): invoice_unit = selected_wave.currency @@ -461,20 +550,53 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="No fiat payment provider configured for this event.", ) + elif payment_method == "onchain": + invoice_unit = "sat" + onchain_amount_sat = int(price) + wallet_record = await get_wallet(event.wallet) + if not wallet_record: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Event wallet does not exist.", + ) + validation = await _validate_watchonly_settings( + wallet=wallet_record, + onchain_enabled=event.extra.onchain_enabled, + onchain_wallet_id=event.extra.onchain_wallet_id, + ) + address_data = await fetch_onchain_address( + wallet_record.inkey, event.extra.onchain_wallet_id or "" + ) + onchain_address = address_data.get("address") + onchain_mempool_endpoint = validation.get("mempool_endpoint") else: invoice_unit = "sat" - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=fiat_amount if payment_method == "fiat" else price, - unit=invoice_unit, - fiat_provider=fiat_provider, - memo=f"{event_id}", - extra=extra, - ), - ) + if payment_method == "onchain": + payment = await create_payment_request( + wallet_id=event.wallet, + invoice_data=CreateInvoice( + out=False, + amount=float(onchain_amount_sat or 0), + unit="sat", + internal=True, + labels=["onchain"], + memo=f"{event_id}", + extra=extra, + ), + ) + else: + payment = await create_payment_request( + wallet_id=event.wallet, + invoice_data=CreateInvoice( + out=False, + amount=fiat_amount if payment_method == "fiat" else price, + unit=invoice_unit, + fiat_provider=fiat_provider, + memo=f"{event_id}", + extra=extra, + ), + ) await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -488,7 +610,10 @@ async def api_ticket_create( "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": str(request.base_url).rstrip("/"), - "sats_paid": payment.sat, + "sats_paid": ( + onchain_amount_sat if payment_method == "onchain" else payment.sat + ), + "onchain": payment_method == "onchain", }, ) @@ -498,6 +623,9 @@ async def api_ticket_create( fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"), fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider, is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider), + onchain_address=onchain_address, + onchain_mempool_endpoint=onchain_mempool_endpoint, + onchain_amount_sat=onchain_amount_sat, ) @@ -559,6 +687,36 @@ async def api_ticket_delete( await delete_ticket(ticket_id) +@tickets_api_router.put("/{payment_hash}/onchain-confirm") +async def api_ticket_onchain_confirm( + payment_hash: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Ticket: + ticket = await get_ticket(payment_hash) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") + if ticket.paid: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Ticket already paid." + ) + if not ticket.extra.onchain: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Ticket is not an onchain payment.", + ) + payment = await get_standalone_payment(payment_hash) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + await internal_invoice_queue_put(payment_hash) + return ticket + + @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( ticket_id: str, From da16b209cd6c5f36b42adbbbc77b8735dbb2c59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Thu, 4 Jun 2026 11:08:59 +0200 Subject: [PATCH 05/37] fix: onchain listeners (#54) * fix: onchain listeners --- views_api.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/views_api.py b/views_api.py index 118b01b..782fd5b 100644 --- a/views_api.py +++ b/views_api.py @@ -16,7 +16,7 @@ from fastapi import ( WebSocketDisconnect, ) from fastapi.responses import StreamingResponse -from lnbits.core.crud import get_standalone_payment, get_user +from lnbits.core.crud import get_user from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import WalletTypeInfo @@ -30,7 +30,6 @@ from lnbits.decorators import ( ) from lnbits.helpers import generate_filter_params_openapi from lnbits.settings import settings -from lnbits.tasks import internal_invoice_queue_put from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, @@ -73,8 +72,14 @@ from .services import ( fetch_watchonly_wallets, refund_tickets, resend_ticket_email_notification, + send_ticket_notification_in_background, + set_ticket_paid, +) +from .tasks import ( + deregister_payment_listener, + payment_listeners, + register_payment_listener, ) -from .tasks import deregister_payment_listener, register_payment_listener events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") @@ -708,12 +713,10 @@ async def api_ticket_onchain_confirm( status_code=HTTPStatus.BAD_REQUEST, detail="Ticket is not an onchain payment.", ) - payment = await get_standalone_payment(payment_hash) - if not payment: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." - ) - await internal_invoice_queue_put(payment_hash) + ticket = await set_ticket_paid(ticket) + send_ticket_notification_in_background(ticket) + for queue in payment_listeners.get(payment_hash, []): + queue.put_nowait(ticket) return ticket From 99b43ff8519bce374c8b7f1cab75cc332750af05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Fri, 5 Jun 2026 12:12:08 +0200 Subject: [PATCH 06/37] fix: use satspay for onchain payments (#56) * use satspay * safeguard satspay * add fasttrack and 0conf --- models.py | 7 +- services.py | 15 +++- static/js/display.js | 54 ++++------- static/js/display.vue | 47 +--------- static/js/index.js | 2 + static/js/index.vue | 19 +++- views_api.py | 203 ++++++++++++++++++++++++------------------ 7 files changed, 170 insertions(+), 177 deletions(-) diff --git a/models.py b/models.py index a16f7dd..ac0d6b5 100644 --- a/models.py +++ b/models.py @@ -47,6 +47,8 @@ class EventExtra(BaseModel): notification_body: str = "" onchain_enabled: bool = False onchain_wallet_id: str | None = None + onchain_zeroconf: bool = False + onchain_fasttrack: bool = False class CreateEvent(BaseModel): @@ -113,6 +115,8 @@ class TicketExtra(BaseModel): nostr_notification_sent: bool = False refunded: bool = False onchain: bool = False + onchain_address: str | None = None + satspay_charge_id: str | None = None class CreateTicket(BaseModel): @@ -170,9 +174,8 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False - onchain_address: str | None = None - onchain_mempool_endpoint: str | None = None onchain_amount_sat: int | None = None + satspay_charge_url: str | None = None class TicketFilters(FilterModel): diff --git a/services.py b/services.py index 84c16e0..fbfac2a 100644 --- a/services.py +++ b/services.py @@ -62,16 +62,27 @@ async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any] return resp.json() -async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: +async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]: async with httpx.AsyncClient() as client: resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}", + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}", headers={"X-API-KEY": api_key}, ) resp.raise_for_status() return resp.json() +async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.post( + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge", + headers={"X-API-KEY": api_key}, + json=data, + ) + resp.raise_for_status() + return resp.json() + + async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/static/js/display.js b/static/js/display.js index a5cc160..3aa4d18 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -27,11 +27,7 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null + isFiat: false }, paymentDismissMsg: null, paymentWebsocket: null @@ -99,16 +95,6 @@ window.PageEventsDisplay = { options.push({label: 'Bitcoin', value: 'onchain'}) } return options - }, - onchainPaymentUri() { - if (!this.receive.onchainAddress) return '' - const btc = (this.receive.onchainAmountSat / 100000000).toFixed(8) - return `bitcoin:${this.receive.onchainAddress}?amount=${btc}` - }, - mempoolAddressUrl() { - if (!this.receive.onchainAddress || !this.receive.mempoolEndpoint) - return null - return `${this.receive.mempoolEndpoint}/address/${this.receive.onchainAddress}` } }, methods: { @@ -161,11 +147,7 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null + isFiat: false } }, nameValidation(val) { @@ -203,11 +185,7 @@ window.PageEventsDisplay = { show: false, status: 'complete', paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null + isFiat: false } this.ticketLink = { show: true, @@ -235,13 +213,15 @@ window.PageEventsDisplay = { : 'lightning' } ) - const isOnchain = Boolean(data.onchain_address) - const isFiat = !isOnchain && Boolean(data.is_fiat) + if (data.satspay_charge_url) { + window.location.href = data.satspay_charge_url + return + } + + const isFiat = Boolean(data.is_fiat) this.paymentReq = isFiat ? data.fiat_payment_request || null - : isOnchain - ? null - : data.payment_request + : data.payment_request this.paymentHash = data.payment_hash this.paymentDismissMsg = Quasar.Notify.create({ @@ -252,11 +232,7 @@ window.PageEventsDisplay = { show: true, status: 'pending', paymentReq: this.paymentReq, - isFiat, - isOnchain, - onchainAddress: data.onchain_address || null, - onchainAmountSat: data.onchain_amount_sat || 0, - mempoolEndpoint: data.onchain_mempool_endpoint || null + isFiat } if (isFiat && this.paymentReq) { window.open(this.paymentReq, '_blank', 'noopener') @@ -291,8 +267,12 @@ window.PageEventsDisplay = { console.error('WebSocket error:', error) } ws.onclose = () => { - if (this.paymentWebsocket === ws) { - this.paymentWebsocket = null + if (this.paymentWebsocket !== ws) return + this.paymentWebsocket = null + if (this.receive.show) { + setTimeout(() => { + if (this.receive.show) this.paymentWatcher(paymentHash) + }, 3000) } } } diff --git a/static/js/display.vue b/static/js/display.vue index d1ab493..074bcc9 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -138,7 +138,7 @@ @@ -173,51 +173,6 @@ Close
- -
-
Pay with Bitcoin
- -
- {{ - (receive.onchainAmountSat / 100000000).toFixed(8) - }} - BTC -
-
- {{ receive.onchainAddress }} -
-
-
- Copy address - View on mempool - Close -
-
- Accept Bitcoin onchain payments. Requires the Watchonly - extension. + Accept Bitcoin onchain payments. Requires the + SatsPay and Watchonly + extensions to be installed and enabled.
+
+ + +
diff --git a/views_api.py b/views_api.py index 782fd5b..b6155e5 100644 --- a/views_api.py +++ b/views_api.py @@ -28,13 +28,14 @@ from lnbits.decorators import ( require_admin_key, require_invoice_key, ) -from lnbits.helpers import generate_filter_params_openapi +from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, get_fiat_rate_satoshis, satoshis_amount_as_fiat, ) +from loguru import logger from PIL import Image, ImageDraw from .crud import ( @@ -66,10 +67,10 @@ from .models import ( get_active_ticket_waves, ) from .services import ( - fetch_onchain_address, + create_satspay_charge, fetch_watchonly_config, - fetch_watchonly_wallet, fetch_watchonly_wallets, + get_satspay_charge, refund_tickets, resend_ticket_email_notification, send_ticket_notification_in_background, @@ -116,47 +117,6 @@ async def _get_watchonly_status(wallet) -> dict[str, Any]: } -async def _validate_watchonly_settings( - *, - wallet, - onchain_enabled: bool, - onchain_wallet_id: str | None, -) -> dict[str, Any]: - if not onchain_enabled: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Onchain payments are not enabled for this event.", - ) - if not onchain_wallet_id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="No watchonly wallet configured for onchain payments.", - ) - status = await _get_watchonly_status(wallet) - if not status["available"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=status["message"] or "Watchonly extension is not available.", - ) - try: - watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id) - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Cannot access watchonly wallet: {exc!s}", - ) from exc - if watch_wallet.get("network") != status["network"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Watchonly wallet network does not match user watchonly config.", - ) - return { - "watch_wallet": watch_wallet, - "network": status["network"], - "mempool_endpoint": status["mempool_endpoint"], - } - - def _is_fiat_currency(currency: str | None) -> bool: return str(currency or "").lower() not in {"sat", "sats"} @@ -528,8 +488,6 @@ async def api_ticket_create( invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None - onchain_address = None - onchain_mempool_endpoint = None onchain_amount_sat = None if payment_method == "fiat": @@ -556,7 +514,6 @@ async def api_ticket_create( detail="No fiat payment provider configured for this event.", ) elif payment_method == "onchain": - invoice_unit = "sat" onchain_amount_sat = int(price) wallet_record = await get_wallet(event.wallet) if not wallet_record: @@ -564,44 +521,82 @@ async def api_ticket_create( status_code=HTTPStatus.NOT_FOUND, detail="Event wallet does not exist.", ) - validation = await _validate_watchonly_settings( - wallet=wallet_record, - onchain_enabled=event.extra.onchain_enabled, - onchain_wallet_id=event.extra.onchain_wallet_id, + if not event.extra.onchain_enabled: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Onchain payments are not enabled for this event.", + ) + if not event.extra.onchain_wallet_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No onchain wallet configured for this event.", + ) + + ticket_id = urlsafe_short_hash() + base_url = str(request.base_url).rstrip("/") + webhook_url = f"{base_url}/events/api/v1/tickets/{ticket_id}/satspay-webhook" + complete_url = f"{base_url}/events/ticket/{ticket_id}" + + try: + charge = await create_satspay_charge( + api_key=wallet_record.inkey, + data={ + "amount": onchain_amount_sat, + "description": f"Ticket for {event.name}", + "name": name, + "onchainwallet": event.extra.onchain_wallet_id, + "zeroconf": event.extra.onchain_zeroconf, + "fasttrack": event.extra.onchain_fasttrack, + "webhook": webhook_url, + "completelink": complete_url, + "completelinktext": "View your ticket", + "time": 1440, + }, + ) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to create SatsPay charge: {exc}", + ) from exc + + await create_ticket( + payment_hash=ticket_id, + wallet=event.wallet, + event=event.id, + name=name, + email=email, + extra={ + "applied_promo_code": promo_code, + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, + "refund_address": refund_address, + "nostr_identifier": nostr_identifier, + "ticket_base_url": base_url, + "sats_paid": onchain_amount_sat, + "onchain": True, + "satspay_charge_id": charge["id"], + }, ) - address_data = await fetch_onchain_address( - wallet_record.inkey, event.extra.onchain_wallet_id or "" + + return TicketPaymentRequest( + payment_hash=ticket_id, + onchain_amount_sat=onchain_amount_sat, + satspay_charge_url=f"/satspay/{charge['id']}", ) - onchain_address = address_data.get("address") - onchain_mempool_endpoint = validation.get("mempool_endpoint") else: invoice_unit = "sat" - if payment_method == "onchain": - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=float(onchain_amount_sat or 0), - unit="sat", - internal=True, - labels=["onchain"], - memo=f"{event_id}", - extra=extra, - ), - ) - else: - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=fiat_amount if payment_method == "fiat" else price, - unit=invoice_unit, - fiat_provider=fiat_provider, - memo=f"{event_id}", - extra=extra, - ), - ) + payment = await create_payment_request( + wallet_id=event.wallet, + invoice_data=CreateInvoice( + out=False, + amount=fiat_amount if payment_method == "fiat" else price, + unit=invoice_unit, + fiat_provider=fiat_provider, + memo=f"{event_id}", + extra=extra, + ), + ) await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -615,10 +610,8 @@ async def api_ticket_create( "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": str(request.base_url).rstrip("/"), - "sats_paid": ( - onchain_amount_sat if payment_method == "onchain" else payment.sat - ), - "onchain": payment_method == "onchain", + "sats_paid": payment.sat, + "onchain": False, }, ) @@ -628,9 +621,6 @@ async def api_ticket_create( fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"), fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider, is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider), - onchain_address=onchain_address, - onchain_mempool_endpoint=onchain_mempool_endpoint, - onchain_amount_sat=onchain_amount_sat, ) @@ -692,6 +682,45 @@ async def api_ticket_delete( await delete_ticket(ticket_id) +@tickets_api_router.post("/{ticket_id}/satspay-webhook") +async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: + ticket = await get_ticket(ticket_id) + if not ticket: + logger.warning(f"SatsPay webhook: ticket {ticket_id} does not exist.") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.paid: + logger.warning(f"SatsPay webhook: ticket {ticket_id} already paid.") + return + if not ticket.extra.satspay_charge_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Not a SatsPay ticket." + ) + wallet = await get_wallet(ticket.wallet) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist." + ) + try: + charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}" + ) from exc + if not charge.get("paid"): + logger.warning( + f"SatsPay webhook for ticket {ticket_id}: charge" + f" {ticket.extra.satspay_charge_id} not paid." + ) + return + + ticket = await set_ticket_paid(ticket) + send_ticket_notification_in_background(ticket) + for queue in payment_listeners.get(ticket_id, []): + queue.put_nowait(ticket) + + @tickets_api_router.put("/{payment_hash}/onchain-confirm") async def api_ticket_onchain_confirm( payment_hash: str, From f745370c33a1655fb5277a483261d87a8b529255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 16 Jun 2026 10:04:03 +0200 Subject: [PATCH 07/37] fix: issue when calling internal webhook from extern (#57) - query from localhost and port, important if behind a proxy without loopback --- views_api.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/views_api.py b/views_api.py index b6155e5..c55ffa0 100644 --- a/views_api.py +++ b/views_api.py @@ -534,9 +534,13 @@ async def api_ticket_create( ticket_id = urlsafe_short_hash() base_url = str(request.base_url).rstrip("/") - webhook_url = f"{base_url}/events/api/v1/tickets/{ticket_id}/satspay-webhook" + # Use internal address for webhook — SatsPay calls it server-side and + # cannot reach the public domain from within the container. + internal_base = f"http://{settings.host}:{settings.port}" + webhook_url = ( + f"{internal_base}/events/api/v1/tickets/{ticket_id}/satspay-webhook" + ) complete_url = f"{base_url}/events/ticket/{ticket_id}" - try: charge = await create_satspay_charge( api_key=wallet_record.inkey, @@ -683,7 +687,7 @@ async def api_ticket_delete( @tickets_api_router.post("/{ticket_id}/satspay-webhook") -async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: +async def api_ticket_satspay_webhook(ticket_id: str) -> None: ticket = await get_ticket(ticket_id) if not ticket: logger.warning(f"SatsPay webhook: ticket {ticket_id} does not exist.") From 72b9c540f7c16663384fd5f55b0f6e490461f472 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 9 Sep 2026 12:34:19 +0200 Subject: [PATCH 08/37] feat: per-event organizer name + reply-to on ticket emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each organizer is different, so the sender identity of ticket emails is now per event rather than per instance: - `extra.organizer_name` → From display name "Organizer via " (site title alone when unset). The From address stays the instance mailbox — that is what DKIM signs — so this costs nothing in mail auth. - `extra.reply_to_email` → Reply-To; blank falls back to the email on the LNbits account that owns the event wallet; no header when neither exists. When a reply-to exists the body says "Questions? Reply to this email and it reaches ", and "Organizer: " is listed with the ticket details. Both fields sit next to the existing per-event subject/body in the admin dialog. The organizer's own wording (in whatever language) remains `notification_body`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- README.md | 5 ++- docs/upstream-candidates.md | 27 ++++++------ models.py | 14 ++++++ services.py | 88 ++++++++++++++++++++++++++++++++----- static/js/index.js | 6 +++ static/js/index.vue | 22 ++++++++++ tests/test_ticket_email.py | 56 +++++++++++++++++++++++ 7 files changed, 193 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index cd02134..9aa5b4d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,10 @@ Events includes a shareable ticket scanner, which can be used to register attend instance logo, name on ticket, ticket id) also served at `GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at `GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a - From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email` + From display name — `extra.organizer_name` gives "Organizer via ", + otherwise the site title. Replies go to `extra.reply_to_email`, falling back + to the event owner's account email (no Reply-To when neither exists). The + From _address_ is always the instance mailbox, which is what DKIM signs. `POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a `TicketResendResult` with per-channel outcome. ## Powered by LNbits diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index 93e6c2f..b5b06d3 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -4,16 +4,17 @@ Running log of fork features that are shaped so they could be offered to `lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here in an upstream-compatible form; strike it when the PR merges upstream. -| Feature | Where | Upstream target | Readiness | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | -| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | -| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | -| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | -| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | -| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | -| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | -| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | -| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | -| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | -| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | +| Feature | Where | Upstream target | Readiness | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | +| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | +| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | +| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | +| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | +| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | +| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | +| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | +| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | +| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | +| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | +| Per-event sender identity: `extra.organizer_name` (From display name "X via ") + `extra.reply_to_email` (Reply-To, falls back to owner account email) | `models.py`, `services.py`, admin dialog | lnbits/events | small, standalone | diff --git a/models.py b/models.py index e36c60a..9a24822 100644 --- a/models.py +++ b/models.py @@ -31,6 +31,20 @@ class EventExtra(BaseModel): nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" + # Sender identity for ticket emails, per event: the From display name + # becomes " via " and replies go to + # `reply_to_email` (falls back to the event owner's account email). + # The From *address* stays the instance mailbox — that is what DKIM + # signs — so organizers get a human sender without touching mail auth. + organizer_name: str = "" + reply_to_email: EmailStr | None = None + + @validator("reply_to_email", pre=True) + def blank_reply_to_is_none(cls, v): + if isinstance(v, str) and not v.strip(): + return None + return v + # Rails the organizer accepts for this event. Empty = legacy rule # ("lightning" always, "fiat" when allow_fiat) — see # `effective_payment_methods`. Same field name/shape as upstream v2 so the diff --git a/services.py b/services.py index 9e6d1ce..2d2644b 100644 --- a/services.py +++ b/services.py @@ -10,6 +10,8 @@ from email.mime.text import MIMEText from email.utils import formataddr, formatdate, make_msgid from html import escape +from lnbits.core.crud import get_user +from lnbits.core.crud.wallets import get_wallet from lnbits.core.models.users import UserNotifications from lnbits.core.services.nostr import send_nostr_dm from lnbits.core.services.notifications import send_user_notification @@ -138,22 +140,38 @@ def _ticket_details(ticket: Ticket, event: Event) -> str: "or open the link above on your phone, and show the QR code at the " "door to be scanned in." ) + if event.extra.organizer_name: + lines.append(f"Organizer: {event.extra.organizer_name}") return "\n".join(lines) -def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: +def _ticket_details_with_reply_hint( + ticket: Ticket, event: Event, reply_to: str | None +) -> str: + details = _ticket_details(ticket, event) + if reply_to: + who = event.extra.organizer_name or "the organizer" + details += f"\nQuestions? Reply to this email and it reaches {who}." + return details + + +def _ticket_delivery_message( + ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None +) -> str: return ( - f"{base_message}\n\n{_ticket_details(ticket, event)}" + f"{base_message}\n\n{_ticket_details_with_reply_hint(ticket, event, reply_to)}" f"\n\nTicket image: {_ticket_image_url(ticket)}" ) -def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: +def _ticket_email_html_message( + ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None +) -> str: """HTML twin of the text part. Deliberately no : the card travels as an attachment (renders inline in most clients, works offline, and keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become links.""" - text_message = _ticket_delivery_message(ticket, event, base_message) + text_message = _ticket_delivery_message(ticket, event, base_message, reply_to) html = escape(text_message) html = re.sub( r"(https?://[^\s<]+)", @@ -163,13 +181,41 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) return f"

{html.replace(chr(10), '
')}

" -def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: +def _ticket_notification_payload( + ticket: Ticket, event: Event, reply_to: str | None = None +) -> tuple[str, str, str]: subject, base_message = _ticket_notification_message(ticket, event) - text_message = _ticket_delivery_message(ticket, event, base_message) - html_message = _ticket_email_html_message(ticket, event, base_message) + text_message = _ticket_delivery_message(ticket, event, base_message, reply_to) + html_message = _ticket_email_html_message(ticket, event, base_message, reply_to) return subject, text_message, html_message +async def organizer_reply_to(event: Event) -> str | None: + """Where replies to a ticket email should go: the event's explicit + `reply_to_email`, else the email on the LNbits account that owns the + event wallet, else nothing (no Reply-To header).""" + if event.extra.reply_to_email: + return str(event.extra.reply_to_email) + try: + wallet = await get_wallet(event.wallet) + if not wallet: + return None + user = await get_user(wallet.user) + email = (user.email or "").strip() if user else "" + return email if is_valid_email_address(email) else None + except Exception as exc: + logger.warning(f"Could not resolve organizer email for {event.id}: {exc}") + return None + + +def organizer_sender_name(event: Event) -> str: + """From display name: " via " when the event + names an organizer, else the site title.""" + site = (settings.lnbits_site_title or "").strip() or "Tickets" + organizer = (event.extra.organizer_name or "").strip() + return f"{organizer} via {site}" if organizer else site + + async def _deliver_ticket_notifications( ticket: Ticket, event: Event, @@ -180,7 +226,10 @@ async def _deliver_ticket_notifications( """Send the ticket by every configured channel and report per-channel outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's opt-ins when not None; the instance-level prerequisites always apply.""" - subject, text_message, html_message = _ticket_notification_payload(ticket, event) + reply_to = await organizer_reply_to(event) + subject, text_message, html_message = _ticket_notification_payload( + ticket, event, reply_to + ) updated = False email_wanted = event.extra.email_notifications if email is None else email @@ -220,6 +269,8 @@ async def _deliver_ticket_notifications( attachments=[ (ticket_card_filename(ticket, event), image_png_bytes(card)) ], + reply_to=reply_to, + sender_name=organizer_sender_name(event), ) ticket.extra.email_notification_sent = True result.email.sent = True @@ -251,6 +302,8 @@ async def _send_ticket_email_notification( subject: str, html_message: str | None = None, attachments: list[tuple[str, bytes]] | None = None, + reply_to: str | None = None, + sender_name: str | None = None, ) -> None: """Multipart (text + HTML) ticket email through the instance SMTP settings. Core's `send_email_notification` is plain-text only, which is @@ -269,7 +322,14 @@ async def _send_ticket_email_notification( raise ValueError(f"Invalid email address: {address}") msg = build_ticket_email( - from_email, to_emails, subject, message, html_message, attachments + from_email, + to_emails, + subject, + message, + html_message, + attachments, + reply_to=reply_to, + sender_name=sender_name, ) username = settings.lnbits_email_notifications_username or from_email await asyncio.to_thread( @@ -291,6 +351,8 @@ def build_ticket_email( message: str, html_message: str | None = None, attachments: list[tuple[str, bytes]] | None = None, + reply_to: str | None = None, + sender_name: str | None = None, ) -> MIMEMultipart: """Assemble the ticket email: text + HTML alternatives, PNG attachments (the ticket card), and the headers receivers score on — a Date and a @@ -312,9 +374,13 @@ def build_ticket_email( else: msg = body - sender_name = (settings.lnbits_site_title or "").strip() or "Tickets" - msg["From"] = formataddr((sender_name, from_email)) + display_name = ( + sender_name or settings.lnbits_site_title or "" + ).strip() or "Tickets" + msg["From"] = formataddr((display_name, from_email)) msg["To"] = ", ".join(to_emails) + if reply_to and is_valid_email_address(reply_to): + msg["Reply-To"] = reply_to msg["Subject"] = subject msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1]) diff --git a/static/js/index.js b/static/js/index.js index 40f98a4..b882497 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -206,6 +206,8 @@ window.PageEvents = { extra: { payment_methods: ['lightning'], promo_codes: [], + organizer_name: '', + reply_to_email: '', notification_subject: '', notification_body: '' } @@ -582,6 +584,8 @@ window.PageEvents = { email_notifications: false, nostr_notifications: false, promo_codes: [], + organizer_name: '', + reply_to_email: '', notification_subject: '', notification_body: '' } @@ -602,6 +606,8 @@ window.PageEvents = { email_notifications: false, nostr_notifications: false, promo_codes: [], + organizer_name: '', + reply_to_email: '', notification_subject: '', notification_body: '' } diff --git a/static/js/index.vue b/static/js/index.vue index 6e7ec28..19b2227 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -823,6 +823,28 @@ label="Ticket notification body" hint="Shown before the ticket link in the paid ticket notification." > +
+
+ +
+
+ +
+
' in html + + +def test_reply_to_and_organizer_sender_name(monkeypatch): + from ..services import organizer_sender_name + + monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!") + event = _event( + extra={ + "organizer_name": "Château du Faune", + "reply_to_email": "hello@faune.example", + } + ) + assert organizer_sender_name(event) == "Château du Faune via Oyez!" + assert organizer_sender_name(_event()) == "Oyez!" + + msg = build_ticket_email( + "tickets@example.org", + ["ada@example.com"], + "Subj", + "text", + None, + reply_to="hello@faune.example", + sender_name=organizer_sender_name(event), + ) + assert msg["Reply-To"] == "hello@faune.example" + # Non-ASCII display names are RFC 2047-encoded on the wire; decode to compare. + from email.header import decode_header, make_header + + assert str(make_header(decode_header(msg["From"]))) == ( + "Château du Faune via Oyez! " + ) + + no_reply = build_ticket_email( + "tickets@example.org", ["ada@example.com"], "Subj", "text" + ) + assert no_reply["Reply-To"] is None + + +def test_reply_hint_only_when_reply_to_exists(monkeypatch): + monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") + event = _event(extra={"organizer_name": "Château du Faune"}) + _, text, _ = _ticket_notification_payload( + _ticket(), event, reply_to="hello@faune.example" + ) + assert "Organizer: Château du Faune" in text + assert "Reply to this email and it reaches Château du Faune" in text + _, text, _ = _ticket_notification_payload(_ticket(), event, reply_to=None) + assert "Reply to this email" not in text + + +def test_blank_reply_to_email_is_none(): + from ..models import EventExtra + + assert EventExtra(reply_to_email="").reply_to_email is None + assert EventExtra(reply_to_email=" ").reply_to_email is None + assert EventExtra(reply_to_email="a@b.example").reply_to_email == "a@b.example" From a44de1aa3f398164049973f4af4a6ecdab74805d Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 13 Sep 2026 17:40:49 +0200 Subject: [PATCH 09/37] feat: "Email sent" column and per-event copy-to-organizer BCC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organizers had no way to see whether a ticket email went out short of reading the Postfix journal, and no copy of what the attendee received — LNbits submits over SMTP, so nothing lands in a Sent folder. - Admin ticket table gains an "Email sent" column derived from `extra.email_notification_sent` ("✓ sent" / "not sent" / "unpaid" / "no email"). - `extra.copy_to_organizer`: BCC every ticket email to the event's reply-to address (explicit, else the owner's account email) on the SMTP envelope only — no Bcc/To header, and skipped when the buyer *is* that address. Toggle sits with the other delivery switches in the admin form. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- models.py | 4 ++++ services.py | 15 ++++++++++++++- static/js/index.js | 16 ++++++++++++++++ static/js/index.vue | 8 ++++++++ tests/test_ticket_email.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/models.py b/models.py index 9a24822..be5fb69 100644 --- a/models.py +++ b/models.py @@ -38,6 +38,10 @@ class EventExtra(BaseModel): # signs — so organizers get a human sender without touching mail auth. organizer_name: str = "" reply_to_email: EmailStr | None = None + # BCC every ticket email to the reply-to address (explicit or the + # owner's account email) so the organizer keeps a copy — LNbits submits + # over SMTP, nothing lands in a Sent folder otherwise. + copy_to_organizer: bool = False @validator("reply_to_email", pre=True) def blank_reply_to_is_none(cls, v): diff --git a/services.py b/services.py index 2d2644b..7969b16 100644 --- a/services.py +++ b/services.py @@ -255,6 +255,13 @@ async def _deliver_ticket_notifications( if result.email.attempted: try: assert ticket.email + bcc = ( + [reply_to] + if event.extra.copy_to_organizer + and reply_to + and reply_to.lower() != ticket.email.lower() + else [] + ) card = render_ticket_card( ticket, event, @@ -271,6 +278,7 @@ async def _deliver_ticket_notifications( ], reply_to=reply_to, sender_name=organizer_sender_name(event), + bcc=bcc, ) ticket.extra.email_notification_sent = True result.email.sent = True @@ -304,6 +312,7 @@ async def _send_ticket_email_notification( attachments: list[tuple[str, bytes]] | None = None, reply_to: str | None = None, sender_name: str | None = None, + bcc: list[str] | None = None, ) -> None: """Multipart (text + HTML) ticket email through the instance SMTP settings. Core's `send_email_notification` is plain-text only, which is @@ -320,6 +329,10 @@ async def _send_ticket_email_notification( for address in to_emails: if not is_valid_email_address(address): raise ValueError(f"Invalid email address: {address}") + # BCC recipients ride only on the SMTP envelope, never in a header. + envelope_recipients = list(to_emails) + [ + b for b in (bcc or []) if is_valid_email_address(b) + ] msg = build_ticket_email( from_email, @@ -339,7 +352,7 @@ async def _send_ticket_email_notification( username, settings.lnbits_email_notifications_password, from_email, - to_emails, + envelope_recipients, msg.as_string(), ) diff --git a/static/js/index.js b/static/js/index.js index b882497..b5c4d1f 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -133,6 +133,19 @@ window.PageEvents = { {name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, + { + name: 'email_sent', + align: 'left', + label: 'Email sent', + field: row => + !row.email + ? 'no email' + : row.extra?.email_notification_sent + ? '\u2713 sent' + : row.paid + ? 'not sent' + : 'unpaid' + }, { name: 'registered', align: 'left', @@ -207,6 +220,7 @@ window.PageEvents = { payment_methods: ['lightning'], promo_codes: [], organizer_name: '', + copy_to_organizer: false, reply_to_email: '', notification_subject: '', notification_body: '' @@ -585,6 +599,7 @@ window.PageEvents = { nostr_notifications: false, promo_codes: [], organizer_name: '', + copy_to_organizer: false, reply_to_email: '', notification_subject: '', notification_body: '' @@ -607,6 +622,7 @@ window.PageEvents = { nostr_notifications: false, promo_codes: [], organizer_name: '', + copy_to_organizer: false, reply_to_email: '', notification_subject: '', notification_body: '' diff --git a/static/js/index.vue b/static/js/index.vue index 19b2227..a8d99ab 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -804,6 +804,14 @@ label="Nostr notifications" left-label > + +
+ Copies go to the reply-to address (or your account email). +
diff --git a/tests/test_ticket_email.py b/tests/test_ticket_email.py index 4ed1560..9bb63e8 100644 --- a/tests/test_ticket_email.py +++ b/tests/test_ticket_email.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone +import pytest from lnbits.settings import settings from ..models import Event, Ticket @@ -143,3 +144,33 @@ def test_blank_reply_to_email_is_none(): assert EventExtra(reply_to_email="").reply_to_email is None assert EventExtra(reply_to_email=" ").reply_to_email is None assert EventExtra(reply_to_email="a@b.example").reply_to_email == "a@b.example" + + +@pytest.mark.asyncio +async def test_copy_to_organizer_is_bcc_on_the_envelope_only(monkeypatch): + from .. import services + + monkeypatch.setattr(settings, "lnbits_email_notifications_enabled", True) + monkeypatch.setattr( + settings, "lnbits_email_notifications_email", "oyez@example.org" + ) + monkeypatch.setattr( + settings, "lnbits_email_notifications_server", "smtp.example.org" + ) + monkeypatch.setattr(settings, "lnbits_email_notifications_port", 587) + monkeypatch.setattr(settings, "lnbits_email_notifications_username", "") + monkeypatch.setattr(settings, "lnbits_email_notifications_password", "x") + sent = {} + + def fake_smtp_send( + server, port, username, password, from_email, recipients, payload + ): + sent.update(recipients=recipients, payload=payload) + + monkeypatch.setattr(services, "_smtp_send", fake_smtp_send) + await services._send_ticket_email_notification( + ["buyer@example.com"], "text", "Subj", None, bcc=["organizer@example.org"] + ) + assert sent["recipients"] == ["buyer@example.com", "organizer@example.org"] + assert "organizer@example.org" not in sent["payload"] # no Bcc/To leak + assert "To: buyer@example.com" in sent["payload"] From f04ff73b287784e1f72a42f505350feed55262e8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 13 Sep 2026 17:41:12 +0200 Subject: [PATCH 10/37] docs: copy-to-organizer + Email sent column Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- README.md | 2 +- docs/upstream-candidates.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9aa5b4d..5ae0639 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Events includes a shareable ticket scanner, which can be used to register attend From display name — `extra.organizer_name` gives "Organizer via ", otherwise the site title. Replies go to `extra.reply_to_email`, falling back to the event owner's account email (no Reply-To when neither exists). The - From _address_ is always the instance mailbox, which is what DKIM signs. `POST /events/api/v1/tickets/{ticket_id}/resend-email` + From _address_ is always the instance mailbox, which is what DKIM signs. `extra.copy_to_organizer` BCCs every ticket email to that reply-to address (envelope only, no header) so the organizer keeps a copy; the admin ticket table shows an "Email sent" column from `extra.email_notification_sent`. `POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a `TicketResendResult` with per-channel outcome. ## Powered by LNbits diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index b5b06d3..2c7e4fb 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -18,3 +18,4 @@ in an upstream-compatible form; strike it when the PR merges upstream. | `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | | Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | | Per-event sender identity: `extra.organizer_name` (From display name "X via ") + `extra.reply_to_email` (Reply-To, falls back to owner account email) | `models.py`, `services.py`, admin dialog | lnbits/events | small, standalone | +| `extra.copy_to_organizer` (envelope BCC of ticket emails to the reply-to) + "Email sent" column in the admin ticket table | `services.py`, `static/js/index.*` | lnbits/events | small, standalone | From 87c74ce13f1ac4c3ae3addcaa648fdd7f89ea2e8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 13 Sep 2026 18:39:52 +0200 Subject: [PATCH 11/37] feat(admin): "Email sent" column in the tickets table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organizers had no way to see whether a ticket email went out short of reading the Postfix journal. Derive a column from the flag the mailer already sets on each ticket (`extra.email_notification_sent`): "✓ sent" / "not sent" (paid, no delivery yet) / "unpaid" / "no email". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- static/js/index.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/static/js/index.js b/static/js/index.js index 40f98a4..3de0898 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -133,6 +133,19 @@ window.PageEvents = { {name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, + { + name: 'email_sent', + align: 'left', + label: 'Email sent', + field: row => + !row.email + ? 'no email' + : row.extra?.email_notification_sent + ? '\u2713 sent' + : row.paid + ? 'not sent' + : 'unpaid' + }, { name: 'registered', align: 'left', From 4c3b1bca31f16f17b6d97a6523128f907bded75f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 13 Sep 2026 18:42:39 +0200 Subject: [PATCH 12/37] chore(release): v1.6.1-aio.14 Since v1.6.1-aio.13: "Email sent" column in the admin tickets table (#48). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 6240778..d2d5cb7 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.13", + "version": "1.6.1-aio.14", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", From 8602bd71e3b4a4ca03a48e645005c1735d6f81b2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:19:44 +0200 Subject: [PATCH 13/37] feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promo handling was inherited from upstream unchanged and had four gaps the webapp was about to put in front of buyers: - `active` was decorative: purchase never read it, so a deactivated code kept discounting. Now rejected with "Promo code is not active." - No redemption cap (#32). `PromoCode.max_uses` (None/0 = unlimited) with `used_count` DERIVED from paid tickets carrying the code in `extra.applied_promo_code` — each ticket of a multi-ticket purchase consumes one use (upstream v2 counts one per basket; documented). Paid-only counting so an abandoned Stripe session can't lock out the last uses for the 24 h unpaid-row lifetime; bounded overshoot under concurrency accepted. - Every code was readable by anyone: `PublicEvent.extra` was the full `EventExtra` and `/events/public` returned the untrimmed `Event` (wallet id included). `EventExtraBase` / `PublicEventExtra` project them out; `/public` now goes through `PublicEvent`. Organizer and admin listings keep the full model, now hydrated with `used_count`. - No preview: `POST /events/api/v1/promo/validate/{event_id}` (same URL as upstream v2; `quantity` replaces v2's `items` since this fork has no ticket types) returns v2-shaped `BasketTotals` + `currency`. Advisory: bad codes are simply absent from `discounts_applied`; purchase still hard-fails them with distinct details. All pricing (validate, invoice, Stripe amount) goes through one pure `basket_totals` with a single rounding rule (whole sats / 2 dp fiat), so the preview equals the charge. Stripe metadata carries `promo_code`; the organizer stats rows carry `applied_promo_code`. `api_event_update` keeps stored codes when the request omits `extra.promo_codes` (explicit `[]` still clears): now that public records don't carry them, a client round-tripping one would otherwise wipe the organizer's codes on every edit. Closes #32 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- __init__.py | 8 +- models.py | 63 ++++++++++++-- promo.py | 108 ++++++++++++++++++++++++ services.py | 17 ++++ tests/test_promo.py | 175 +++++++++++++++++++++++++++++++++++++++ tests/test_promo_api.py | 178 ++++++++++++++++++++++++++++++++++++++++ views_api.py | 86 ++++++++++++++++--- 7 files changed, 617 insertions(+), 18 deletions(-) create mode 100644 promo.py create mode 100644 tests/test_promo.py create mode 100644 tests/test_promo_api.py diff --git a/__init__.py b/__init__.py index 394bc6d..a0b330d 100644 --- a/__init__.py +++ b/__init__.py @@ -6,13 +6,19 @@ from loguru import logger from .crud import db from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import events_api_router, qr_api_router, tickets_api_router +from .views_api import ( + events_api_router, + promo_api_router, + qr_api_router, + tickets_api_router, +) events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) events_ext.include_router(tickets_api_router) events_ext.include_router(qr_api_router) +events_ext.include_router(promo_api_router) events_static_files = [ { diff --git a/models.py b/models.py index e36c60a..b349794 100644 --- a/models.py +++ b/models.py @@ -11,20 +11,41 @@ class PromoCode(BaseModel): code: str discount_percent: float = 0.0 active: bool = True + # Redemption cap; None / 0 = unlimited. Field names follow upstream v2. + max_uses: int | None = None + # Derived on read from PAID tickets whose extra.applied_promo_code matches + # (see promo.promo_usage / services.hydrate_promo_usage). Whatever a + # client sends back here is ignored — it is never the source of truth. + used_count: int = 0 - # make the promo code uppercase + # stored form: stripped + upper-case, never empty @validator("code") def uppercase_code(cls, v): - return v.upper() + v = (v or "").strip().upper() + if not v: + raise ValueError("Promo code cannot be empty.") + return v @validator("discount_percent") def validate_discount_percent(cls, v): assert 0 <= v <= 100, "Discount must be between 0 and 100." return v + @validator("max_uses", pre=True) + def normalize_max_uses(cls, v): + if v in (None, "", 0, "0"): + return None + v = int(v) + if v < 1: + raise ValueError("max_uses must be at least 1.") + return v + + +class EventExtraBase(BaseModel): + """Everything in `extra` that is safe to show anyone. `EventExtra` adds + the organizer-only promo codes on top; `PublicEventExtra` is this base, + so anonymous responses can never carry them.""" -class EventExtra(BaseModel): - promo_codes: list[PromoCode] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 email_notifications: bool = False @@ -53,6 +74,13 @@ class EventExtra(BaseModel): return seen +class EventExtra(EventExtraBase): + promo_codes: list[PromoCode] = Field(default_factory=list) + + +PublicEventExtra = EventExtraBase + + class CreateEvent(BaseModel): wallet: str | None = None # filled from caller's wallet if absent name: str # title (required) @@ -120,7 +148,9 @@ class PublicEvent(BaseModel): banner: str | None location: str | None = None categories: list[str] = Field(default_factory=list) - extra: EventExtra = Field(default_factory=EventExtra) + # PublicEventExtra: promo codes are organizer-only (a buyer who can read + # every code can mint every discount). + extra: PublicEventExtra = Field(default_factory=PublicEventExtra) status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner @validator("categories", pre=True) @@ -146,6 +176,29 @@ def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> lis return methods +class PromoValidateRequest(BaseModel): + """Upstream v2 shape. v2 sends `items` (ticket types); this fork has one + price per event, so a plain `quantity` replaces it.""" + + codes: list[str] = Field(default_factory=list) + quantity: int = Field(default=1, ge=1, le=10) + + +class BasketDiscount(BaseModel): + code: str + discount_percent: float | None = None + discount_fixed: int | None = None # always None here (percent-only); v2 shape + amount_saved: float = 0 + + +class BasketTotals(BaseModel): + subtotal: float = 0 + discount: float = 0 + total: float = 0 + discounts_applied: list[BasketDiscount] = Field(default_factory=list) + currency: str = "sat" # fork addition so a client can format the numbers + + class EventsSettings(BaseModel): """Extension-level settings for the events extension.""" diff --git a/promo.py b/promo.py new file mode 100644 index 0000000..4bfa8ff --- /dev/null +++ b/promo.py @@ -0,0 +1,108 @@ +"""Promo-code arithmetic shared by the validate endpoint and the purchase path. + +Pure functions (no DB, no settings) so the number a buyer sees in the +"Apply" preview is exactly the number the invoice / Stripe session charges. +Field names and the `BasketTotals` shape follow upstream lnbits/events v2 +(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted +inline. +""" + +from __future__ import annotations + +from collections import Counter + +from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket + +SAT_UNITS = ("sat", "sats") + + +def normalize_code(raw: str | None) -> str | None: + """Buyer input → stored form (stripped, upper-cased); empty → None.""" + if raw is None: + return None + code = raw.strip().upper() + return code or None + + +def find_promo(event: Event, code: str) -> PromoCode | None: + return next((pc for pc in event.extra.promo_codes if pc.code == code), None) + + +def promo_usage(tickets: list[Ticket]) -> dict[str, int]: + """Redemptions per code = PAID tickets carrying it in + `extra.applied_promo_code`. Every row counts, so a multi-ticket + purchase consumes `quantity` uses (upstream v2 counts one per basket). + + Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so + counting them would let an abandoned Stripe session lock out the last + uses of a limited code for a day. The cost is a bounded overshoot when + several buyers pass the check before any of them pays — accepted. + """ + counter: Counter[str] = Counter() + for ticket in tickets: + code = ticket.extra.applied_promo_code + if ticket.paid and code: + counter[code] += 1 + return dict(counter) + + +def remaining_uses(promo: PromoCode, used: int) -> int | None: + """None = unlimited (`max_uses` unset / 0).""" + if not promo.max_uses: + return None + return max(promo.max_uses - used, 0) + + +def round_amount(amount: float, currency: str | None) -> float: + """Sats are integers; fiat is 2 dp. Applied once, at the end, so + subtotal - total == discount holds for what is actually charged.""" + if (currency or "sat").lower() in SAT_UNITS: + return float(int(amount)) + return round(amount, 2) + + +def basket_totals( + event: Event, + codes: list[str], + quantity: int, + usage: dict[str, int], +) -> BasketTotals: + """Price `quantity` tickets with the first applicable code in `codes`. + + A code is applicable when it exists, is active, has enough uses left + for the whole quantity, and actually saves something. Anything else is + simply absent from `discounts_applied` (upstream v2 semantics — the + purchase endpoint is where hard errors are raised). Only one code is + applied; v2's `combinable` stacking is out of scope here. + """ + currency = event.currency or "sat" + subtotal = round_amount(event.price_per_ticket * quantity, currency) + totals = BasketTotals( + subtotal=subtotal, discount=0, total=subtotal, currency=currency + ) + for raw in codes: + code = normalize_code(raw) + if not code: + continue + promo = find_promo(event, code) + if not promo or not promo.active: + continue + remaining = remaining_uses(promo, usage.get(promo.code, 0)) + if remaining is not None and remaining < quantity: + continue + total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency) + saved = round_amount(subtotal - total, currency) + if saved <= 0: + continue + totals.total = total + totals.discount = saved + totals.discounts_applied = [ + BasketDiscount( + code=promo.code, + discount_percent=promo.discount_percent, + discount_fixed=None, + amount_saved=saved, + ) + ] + break + return totals diff --git a/services.py b/services.py index 9e6d1ce..f69b76e 100644 --- a/services.py +++ b/services.py @@ -28,6 +28,7 @@ from .crud import ( ) from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult from .nostr_hooks import publish_or_delete_nostr_event +from .promo import promo_usage from .qr import ( format_event_when, image_png_bytes, @@ -82,6 +83,22 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: return ticket +async def event_promo_usage(event_id: str) -> dict[str, int]: + """Paid redemptions per promo code for one event (see promo.promo_usage).""" + return promo_usage(await get_event_tickets(event_id)) + + +async def hydrate_promo_usage(event: Event) -> Event: + """Fill `used_count` on each of the event's promo codes. No query when + the event has no codes, so listing stays cheap.""" + if not event.extra.promo_codes: + return event + usage = await event_promo_usage(event.id) + for promo in event.extra.promo_codes: + promo.used_count = usage.get(promo.code, 0) + return event + + def send_ticket_notification_in_background(ticket: Ticket) -> None: create_task(_send_ticket_notification(ticket)) diff --git a/tests/test_promo.py b/tests/test_promo.py new file mode 100644 index 0000000..a4067c3 --- /dev/null +++ b/tests/test_promo.py @@ -0,0 +1,175 @@ +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from ..models import Event, EventExtra, PromoCode, PublicEvent, Ticket +from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses + + +def _event(currency="sat", price=1000.0, codes=None) -> Event: + return Event( + id="evt", + wallet="w", + name="Test", + info="", + closing_date="2030-01-01", + event_start_date="2030-01-01", + event_end_date="2030-01-02", + currency=currency, + price_per_ticket=price, + amount_tickets=10, + time=datetime.now(timezone.utc), + extra=EventExtra(promo_codes=codes or []), + ) + + +def _ticket(code, paid=True) -> Ticket: + now = datetime.now(timezone.utc) + return Ticket( + id=f"t-{code}-{paid}", + wallet="w", + event="evt", + registered=False, + paid=paid, + time=now, + reg_timestamp=now, + extra={"applied_promo_code": code}, + ) + + +# --- model ----------------------------------------------------------------- + + +def test_code_is_stripped_and_uppercased(): + assert PromoCode(code=" half ", discount_percent=50).code == "HALF" + + +def test_empty_code_is_rejected(): + with pytest.raises(ValidationError): + PromoCode(code=" ", discount_percent=10) + + +@pytest.mark.parametrize( + "raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)] +) +def test_max_uses_normalisation(raw, expected): + assert PromoCode(code="X", max_uses=raw).max_uses == expected + + +def test_max_uses_below_one_rejected(): + with pytest.raises(ValidationError): + PromoCode(code="X", max_uses=-1) + + +def test_discount_bounds(): + with pytest.raises(ValidationError): + PromoCode(code="X", discount_percent=101) + + +def test_public_event_projection_drops_promo_codes(): + event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)]) + public = PublicEvent.parse_obj(event.dict()).dict() + assert "promo_codes" not in public["extra"] + assert public["extra"]["payment_methods"] == [] + # the full model keeps them + assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET" + + +# --- helpers --------------------------------------------------------------- + + +def test_normalize_code(): + assert normalize_code(" save20 ") == "SAVE20" + assert normalize_code("") is None + assert normalize_code(None) is None + + +def test_promo_usage_counts_paid_rows_only_per_ticket(): + usage = promo_usage( + [_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)] + ) + assert usage == {"HALF": 2} + + +def test_remaining_uses(): + assert remaining_uses(PromoCode(code="X"), 5) is None + assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2 + assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0 + + +# --- basket_totals ----------------------------------------------------------- + + +def test_sat_totals_round_to_whole_sats(): + event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)]) + totals = basket_totals(event, ["off15"], 1, {}) + assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50) + assert totals.currency == "sat" + assert totals.discounts_applied[0].dict() == { + "code": "OFF15", + "discount_percent": 15, + "discount_fixed": None, + "amount_saved": 50, + } + + +def test_fiat_totals_round_to_cents_and_scale_by_quantity(): + event = _event( + currency="EUR", + price=19.99, + codes=[PromoCode(code="THIRD", discount_percent=33)], + ) + totals = basket_totals(event, ["THIRD"], 3, {}) + assert totals.subtotal == 59.97 + assert totals.total == 40.18 + assert totals.discount == 19.79 + assert totals.discount + totals.total == totals.subtotal + + +def test_first_applicable_code_wins(): + event = _event( + codes=[ + PromoCode(code="A", discount_percent=10), + PromoCode(code="B", discount_percent=50), + ] + ) + assert ( + basket_totals(event, ["NOPE", "B", "A"], 1, {}).discounts_applied[0].code == "B" + ) + + +def test_inactive_unknown_zero_and_exhausted_codes_are_absent(): + event = _event( + codes=[ + PromoCode(code="OLD", discount_percent=20, active=False), + PromoCode(code="ZERO", discount_percent=0), + PromoCode(code="TWO", discount_percent=50, max_uses=2), + ] + ) + for codes, usage, qty in ( + (["OLD"], {}, 1), + (["ZERO"], {}, 1), + (["NOPE"], {}, 1), + (["TWO"], {"TWO": 2}, 1), + (["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity + ): + totals = basket_totals(event, codes, qty, usage) + assert totals.discounts_applied == [] + assert totals.total == totals.subtotal and totals.discount == 0 + + +def test_unlimited_and_partially_used_codes_apply(): + event = _event( + codes=[ + PromoCode(code="TWO", discount_percent=50, max_uses=2), + PromoCode(code="INF", discount_percent=10), + ] + ) + assert basket_totals(event, ["TWO"], 1, {"TWO": 1}).total == 500 + assert basket_totals(event, ["INF"], 10, {"INF": 999}).total == 9000 + + +def test_full_discount_prices_to_zero(): + event = _event(codes=[PromoCode(code="FREE", discount_percent=100)]) + assert basket_totals(event, ["FREE"], 2, {}).total == 0 diff --git a/tests/test_promo_api.py b/tests/test_promo_api.py new file mode 100644 index 0000000..65d1c62 --- /dev/null +++ b/tests/test_promo_api.py @@ -0,0 +1,178 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from .. import views_api +from ..models import CreateTicket, Event, EventExtra, PromoCode, PromoValidateRequest + + +def _event(codes) -> Event: + return Event( + id="evt", + wallet="w", + name="Test", + info="", + closing_date="2030-01-01", + event_start_date="2030-01-01", + event_end_date="2030-01-02", + currency="sat", + price_per_ticket=1000, + amount_tickets=10, + time=datetime.now(timezone.utc), + extra=EventExtra(promo_codes=codes), + status="approved", + ) + + +@pytest.fixture +def event(monkeypatch): + ev = _event( + [ + PromoCode(code="HALF", discount_percent=50, max_uses=2), + PromoCode(code="OLD", discount_percent=20, active=False), + ] + ) + monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=ev)) + monkeypatch.setattr( + views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 1}) + ) + return ev + + +@pytest.mark.asyncio +async def test_validate_returns_v2_shaped_totals(event): + totals = await views_api.api_validate_promo_codes( + "evt", PromoValidateRequest(codes=["half"], quantity=1) + ) + assert totals.dict() == { + "subtotal": 1000, + "discount": 500, + "total": 500, + "currency": "sat", + "discounts_applied": [ + { + "code": "HALF", + "discount_percent": 50, + "discount_fixed": None, + "amount_saved": 500, + } + ], + } + + +@pytest.mark.asyncio +async def test_validate_is_advisory_for_bad_codes(event): + for codes, qty in ((["OLD"], 1), (["NOPE"], 1), (["HALF"], 2)): + totals = await views_api.api_validate_promo_codes( + "evt", PromoValidateRequest(codes=codes, quantity=qty) + ) + assert totals.discounts_applied == [] and totals.total == totals.subtotal + + +@pytest.mark.asyncio +async def test_validate_unknown_event_is_404(monkeypatch): + monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=None)) + with pytest.raises(HTTPException) as exc: + await views_api.api_validate_promo_codes( + "nope", PromoValidateRequest(codes=["X"]) + ) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "code,quantity,detail", + [ + ("NOPE", 1, "Invalid promo code."), + ("old", 1, "Promo code is not active."), + ("HALF", 2, "Only 1 use(s) left on this promo code."), + ], +) +async def test_purchase_rejects_bad_codes_before_any_invoice( + event, monkeypatch, code, quantity, detail +): + monkeypatch.setattr( + views_api, + "create_payment_request", + AsyncMock(side_effect=AssertionError("must not be called")), + ) + data = CreateTicket(user_id="u1", promo_code=code, quantity=quantity) + with pytest.raises(HTTPException) as exc: + await views_api.api_ticket_create( + "evt", data, SimpleNamespace(base_url="http://lnbits.local/") + ) + assert exc.value.status_code == 400 + assert exc.value.detail == detail + + +@pytest.mark.asyncio +async def test_purchase_reports_fully_redeemed(event, monkeypatch): + monkeypatch.setattr( + views_api, "event_promo_usage", AsyncMock(return_value={"HALF": 2}) + ) + monkeypatch.setattr( + views_api, + "create_payment_request", + AsyncMock(side_effect=AssertionError("must not be called")), + ) + with pytest.raises(HTTPException) as exc: + await views_api.api_ticket_create( + "evt", + CreateTicket(user_id="u1", promo_code="HALF"), + SimpleNamespace(base_url="http://lnbits.local/"), + ) + assert exc.value.detail == "Promo code has been fully redeemed." + + +@pytest.fixture +def update_env(monkeypatch): + stored = _event([PromoCode(code="KEEP", discount_percent=10)]) + monkeypatch.setattr(views_api, "get_event", AsyncMock(return_value=stored)) + monkeypatch.setattr( + views_api, + "get_settings", + AsyncMock(return_value=SimpleNamespace(auto_approve=True)), + ) + monkeypatch.setattr(views_api, "update_event", AsyncMock(side_effect=lambda e: e)) + monkeypatch.setattr(views_api, "publish_or_delete_nostr_event", AsyncMock()) + return stored + + +def _update_payload(extra: dict) -> dict: + return { + "wallet": "w", + "name": "Test", + "info": "", + "closing_date": "2030-01-01", + "event_start_date": "2030-01-01", + "event_end_date": "2030-01-02", + "amount_tickets": 10, + "price_per_ticket": 1000, + "extra": extra, + } + + +def _wallet(): + return SimpleNamespace(wallet=SimpleNamespace(id="w", user="u1")) + + +@pytest.mark.asyncio +async def test_update_without_promo_key_keeps_stored_codes(update_env): + from ..models import CreateEvent + + data = CreateEvent.parse_obj(_update_payload({"email_notifications": True})) + event = await views_api.api_event_update("evt", data, _wallet()) + assert [pc.code for pc in event.extra.promo_codes] == ["KEEP"] + assert event.extra.email_notifications is True + + +@pytest.mark.asyncio +async def test_update_with_empty_promo_list_clears(update_env): + from ..models import CreateEvent + + data = CreateEvent.parse_obj(_update_payload({"promo_codes": []})) + event = await views_api.api_event_update("evt", data, _wallet()) + assert event.extra.promo_codes == [] diff --git a/views_api.py b/views_api.py index ac0b0ba..039bc4f 100644 --- a/views_api.py +++ b/views_api.py @@ -59,10 +59,12 @@ from .crud import ( update_ticket, ) from .models import ( + BasketTotals, CreateEvent, CreateTicket, Event, EventsSettings, + PromoValidateRequest, PublicEvent, PublicTicket, Ticket, @@ -71,6 +73,13 @@ from .models import ( effective_payment_methods, ) from .nostr_hooks import publish_or_delete_nostr_event +from .promo import ( + basket_totals, + find_promo, + normalize_code, + remaining_uses, + round_amount, +) from .qr import ( image_png_bytes, load_qr_logo, @@ -79,6 +88,8 @@ from .qr import ( ticket_card_filename, ) from .services import ( + event_promo_usage, + hydrate_promo_usage, refund_tickets, resend_ticket_email_notification, send_ticket_notification_in_background, @@ -89,6 +100,7 @@ from .tasks import deregister_payment_listener, register_payment_listener events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") qr_api_router = APIRouter(prefix="/api/v1") +promo_api_router = APIRouter(prefix="/api/v1/promo") def _is_fiat_currency(currency: str | None) -> bool: @@ -108,12 +120,17 @@ async def api_events( if all_wallets: user = await get_user(wallet.wallet.user) wallet_ids = user.wallet_ids if user else [] - return await get_events(wallet_ids) + events = await get_events(wallet_ids) + for event in events: + await hydrate_promo_usage(event) + return events -@events_api_router.get("/public") +@events_api_router.get("/public", response_model=list[PublicEvent]) async def api_events_public() -> list[Event]: - """Approved, non-canceled events for an anonymous public listing.""" + """Approved, non-canceled events for an anonymous public listing. + + Projected through `PublicEvent`: no wallet id, no promo codes.""" return await get_public_events() @@ -131,6 +148,7 @@ async def api_events_all( events = await get_all_events() enriched: list[dict] = [] for event in events: + await hydrate_promo_usage(event) wallet = await get_wallet(event.wallet) row = event.dict() row["wallet_user_id"] = wallet.user if wallet else None @@ -353,6 +371,13 @@ async def api_event_update( if not data.closing_date: data.closing_date = data.event_end_date + # Promo codes are organizer-only and absent from public responses, so a + # client that round-trips a public record (or simply doesn't manage + # codes) would otherwise wipe them on every edit. Carry the stored list + # over unless the request names the key; an explicit `[]` still clears. + if "promo_codes" not in data.extra.__fields_set__: + data.extra.promo_codes = event.extra.promo_codes + # Explicit field list — never copy `status` from the request body. # Includes upstream v1.6.1 fields (allow_fiat, fiat_currency) so an # owner editing a fiat-enabled event keeps the fiat config. @@ -660,7 +685,7 @@ async def api_ticket_create( name = data.name email = data.email user_id = data.user_id - promo_code = data.promo_code.upper() if data.promo_code else None + promo_code = normalize_code(data.promo_code) refund_address = data.refund_address nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None payment_method = (data.payment_method or "lightning").lower() @@ -677,22 +702,39 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, detail="Invalid Nostr identifier.", ) from exc - unit_price = event.price_per_ticket extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} frontend_root = _resolve_frontend_root(data, request) + # One invoice, N tickets; the promo (if any) prices the whole quantity + # through the same `basket_totals` the validate endpoint uses, so the + # preview a buyer saw is what gets charged. Unlike validate, a bad code + # is a hard error here — silently charging full price would be worse. if promo_code: - # check if promo_code exists in event.extra.promo_codes - if promo_code not in [pc.code for pc in event.extra.promo_codes]: + promo = find_promo(event, promo_code) + if not promo: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." ) - # get the promocode - promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) + if not promo.active: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Promo code is not active.", + ) + usage = await event_promo_usage(event.id) + uses_left = remaining_uses(promo, usage.get(promo.code, 0)) + if uses_left is not None and uses_left < quantity: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + "Promo code has been fully redeemed." + if uses_left == 0 + else f"Only {uses_left} use(s) left on this promo code." + ), + ) extra["promo_code"] = promo.code - unit_price = event.price_per_ticket * (1 - promo.discount_percent / 100) - # Scale by quantity AFTER the promo applies. One invoice, N tickets. - price = unit_price * quantity + price = basket_totals(event, [promo.code], quantity, usage).total + else: + price = round_amount(event.price_per_ticket * quantity, event.currency) # Free tickets (final charge 0 — a free event or a 100%-off promo). # Short-circuit before any invoice / fiat-provider logic: no Lightning @@ -784,6 +826,7 @@ async def api_ticket_create( "event_id": event.id, "quantity": str(quantity), "ticket_ids": ",".join(ticket_ids), + **({"promo_code": promo_code} if promo_code else {}), }, } @@ -1042,6 +1085,7 @@ async def api_event_ticket_stats( "registered_at": ( t.reg_timestamp.isoformat() if t.reg_timestamp else None ), + "applied_promo_code": t.extra.applied_promo_code, } for t in paid_tickets ], @@ -1100,3 +1144,21 @@ async def api_ticket_card(ticket_id: str): "Cache-Control": "no-cache, no-store, must-revalidate", }, ) + + +@promo_api_router.post("/validate/{event_id}") +async def api_validate_promo_codes( + event_id: str, data: PromoValidateRequest +) -> BasketTotals: + """Price a purchase with the given codes without committing to it — + what the buyer sees before paying. Anonymous and advisory: a code that + is unknown / inactive / exhausted is simply absent from + `discounts_applied`; the purchase endpoint is where hard errors live. + Same URL as upstream v2 (`quantity` instead of v2's `items`).""" + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + usage = await event_promo_usage(event_id) if event.extra.promo_codes else {} + return basket_totals(event, data.codes, data.quantity, usage) From 07519bec1bebc2f68b4bce4a8a0b7755bee605e1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 12:19:44 +0200 Subject: [PATCH 14/37] feat(admin): max uses column + used count in the promo editor The Quasar editor gains a "Max uses" input per code (blank = unlimited, normalised to null on save) and shows "Used n / max" from the derived count. The public buy page's Clear button now also clears the promo field. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas --- static/js/display.js | 1 + static/js/index.js | 4 +++- static/js/index.vue | 30 +++++++++++++++++++++++++++--- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/static/js/display.js b/static/js/display.js index ad7e358..f687512 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -94,6 +94,7 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' + this.formDialog.data.promo_code = '' this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning' }, diff --git a/static/js/index.js b/static/js/index.js index 3de0898..962203a 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -526,7 +526,9 @@ window.PageEvents = { .filter(code => code.code?.trim() !== '') .map(code => ({ ...code, - code: code.code.trim().toUpperCase() + code: code.code.trim().toUpperCase(), + // blank / 0 = unlimited; used_count is derived server-side + max_uses: code.max_uses ? Number(code.max_uses) : null })) } const methods = data.extra?.payment_methods || [] diff --git a/static/js/index.vue b/static/js/index.vue index 6e7ec28..d53f4c8 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -729,12 +729,22 @@ class="row q-col-gutter-sm q-mt-md" >