Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
4c6a5b3caf Email campaign and CSV import 2026-02-10 19:21:45 +00:00
31 changed files with 818 additions and 181 deletions

Binary file not shown.

View File

@ -1,3 +1,20 @@
from django.contrib import admin from django.contrib import admin
from .models import Property, Guest, Stay, Campaign
# Register your models here. @admin.register(Property)
class PropertyAdmin(admin.ModelAdmin):
list_display = ('name', 'address', 'created_at')
@admin.register(Guest)
class GuestAdmin(admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'email', 'phone', 'created_at')
search_fields = ('first_name', 'last_name', 'email')
@admin.register(Stay)
class StayAdmin(admin.ModelAdmin):
list_display = ('guest', 'property', 'check_in', 'check_out', 'total_nights')
list_filter = ('property', 'check_in')
@admin.register(Campaign)
class CampaignAdmin(admin.ModelAdmin):
list_display = ('title', 'subject', 'status', 'sent_at')

50
core/mail.py Normal file
View File

@ -0,0 +1,50 @@
import logging
from django.core.mail import send_mail, EmailMultiAlternatives
from django.conf import settings
from django.utils.html import strip_tags
logger = logging.getLogger(__name__)
def send_campaign_email(campaign, recipients):
"""
Sends a campaign email to a list of recipients.
"""
subject = campaign.subject
html_content = campaign.body
text_content = strip_tags(html_content)
from_email = settings.DEFAULT_FROM_EMAIL
success_count = 0
fail_count = 0
for recipient in recipients:
try:
msg = EmailMultiAlternatives(subject, text_content, from_email, [recipient.email])
msg.attach_alternative(html_content, "text/html")
msg.send()
success_count += 1
except Exception as e:
logger.error(f"Failed to send email to {recipient.email}: {e}")
fail_count += 1
return success_count, fail_count
def send_contact_message(name, email, message):
"""
Simple wrapper for contact form emails.
"""
subject = f"New Contact Message from {name}"
body = f"From: {name} <{email}>\n\nMessage:\n{message}"
try:
send_mail(
subject,
body,
settings.DEFAULT_FROM_EMAIL,
settings.CONTACT_EMAIL_TO,
fail_silently=False,
)
return True
except Exception as e:
logger.error(f"Failed to send contact message: {e}")
return False

View File

Binary file not shown.

View File

View File

@ -0,0 +1,47 @@
from django.core.management.base import BaseCommand
from core.models import Property, Guest, Stay, Campaign
from django.utils import timezone
import datetime
class Command(BaseCommand):
help = 'Seeds the database with sample data'
def handle(self, *args, **kwargs):
# Create Properties
p1, _ = Property.objects.get_or_create(name="Ocean View Apartment", address="123 Beach Blvd, Miami")
p2, _ = Property.objects.get_or_create(name="Mountain Cabin", address="456 Pine Rd, Aspen")
p3, _ = Property.objects.get_or_create(name="City Loft", address="789 Main St, New York")
# Create Guests
g1, _ = Guest.objects.get_or_create(first_name="John", last_name="Doe", email="john@example.com", phone="123-456-7890")
g2, _ = Guest.objects.get_or_create(first_name="Jane", last_name="Smith", email="jane@example.com", phone="987-654-3210")
g3, _ = Guest.objects.get_or_create(first_name="Alice", last_name="Johnson", email="alice@example.com")
# Create Stays
Stay.objects.get_or_create(guest=g1, property=p1, check_in=datetime.date(2025, 1, 10), check_out=datetime.date(2025, 1, 15))
Stay.objects.get_or_create(guest=g2, property=p2, check_in=datetime.date(2025, 2, 1), check_out=datetime.date(2025, 2, 10))
Stay.objects.get_or_create(guest=g3, property=p3, check_in=datetime.date(2025, 2, 5), check_out=datetime.date(2025, 2, 12))
Stay.objects.get_or_create(guest=g1, property=p3, check_in=datetime.date(2026, 1, 5), check_out=datetime.date(2026, 1, 10))
# Create Campaigns
Campaign.objects.get_or_create(
title="Spring Discount Offer",
subject="Special 20% Off Your Next Stay!",
body="<h1>Spring is here!</h1><p>We'd love to have you back. Use code SPRING20 for 20% off your next booking at any of our properties.</p>",
status='draft'
)
Campaign.objects.get_or_create(
title="Refer a Friend Program",
subject="Share the Love, Get a Reward",
body="<p>Refer a friend to our apartments and get $50 credit for your next stay!</p>",
status='draft'
)
Campaign.objects.get_or_create(
title="Welcome Back",
subject="We missed you!",
body="<p>It's been a while since your last stay. Come visit us again soon!</p>",
status='sent',
sent_at=timezone.now() - datetime.timedelta(days=30)
)
self.stdout.write(self.style.SUCCESS('Successfully seeded sample data including campaigns'))

View File

@ -0,0 +1,59 @@
# Generated by Django 5.2.7 on 2026-02-10 18:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Campaign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('subject', models.CharField(max_length=255)),
('body', models.TextField()),
('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent')], default='draft', max_length=10)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Guest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(max_length=100)),
('last_name', models.CharField(max_length=100)),
('email', models.EmailField(max_length=254, unique=True)),
('phone', models.CharField(blank=True, max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Property',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('address', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Stay',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('check_in', models.DateField()),
('check_out', models.DateField()),
('total_nights', models.IntegerField(default=0, editable=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('guest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stays', to='core.guest')),
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stays', to='core.property')),
],
),
]

View File

@ -1,3 +1,52 @@
from django.db import models from django.db import models
from django.conf import settings
# Create your models here. class Property(models.Model):
name = models.CharField(max_length=255)
address = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Guest(models.Model):
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
email = models.EmailField(unique=True)
phone = models.CharField(max_length=20, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.first_name} {self.last_name}"
class Stay(models.Model):
guest = models.ForeignKey(Guest, on_delete=models.CASCADE, related_name='stays')
property = models.ForeignKey(Property, on_delete=models.CASCADE, related_name='stays')
check_in = models.DateField()
check_out = models.DateField()
total_nights = models.IntegerField(editable=False, default=0)
created_at = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
if self.check_in and self.check_out:
delta = self.check_out - self.check_in
self.total_nights = delta.days
super().save(*args, **kwargs)
def __str__(self):
return f"{self.guest} at {self.property} ({self.check_in} to {self.check_out})"
class Campaign(models.Model):
STATUS_CHOICES = [
('draft', 'Draft'),
('sent', 'Sent'),
]
title = models.CharField(max_length=255)
subject = models.CharField(max_length=255)
body = models.TextField()
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
sent_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title

View File

@ -1,25 +1,151 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if project_description %} <title>{% block title %}{{ project_name }}{% endblock %}</title>
<meta name="description" content="{{ project_description }}"> <meta name="description" content="Airbnb Host Loyalty CRM - Manage guests, properties and campaigns.">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}"> <!-- Fonts -->
{% endif %} <link rel="preconnect" href="https://fonts.googleapis.com">
{% if project_image_url %} <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<meta property="og:image" content="{{ project_image_url }}"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Lexend:wght@600;700&display=swap" rel="stylesheet">
<meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} <!-- Bootstrap 5 CSS CDN -->
{% load static %} <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} <!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={% now 'U' %}">
<style>
:root {
--primary-slate: #1E293B;
--accent-teal: #0D9488;
--bg-soft: #F8FAFC;
--text-main: #334155;
--glass: rgba(255, 255, 255, 0.7);
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-soft);
color: var(--text-main);
min-height: 100vh;
}
h1, h2, h3, h4, .navbar-brand {
font-family: 'Lexend', sans-serif;
color: var(--primary-slate);
}
.navbar {
background-color: white;
border-bottom: 1px solid #E2E8F0;
padding: 1rem 0;
}
.nav-link {
font-weight: 500;
color: #64748B;
transition: color 0.2s;
}
.nav-link:hover, .nav-link.active {
color: var(--accent-teal);
}
.btn-primary {
background-color: var(--accent-teal);
border-color: var(--accent-teal);
border-radius: 8px;
padding: 0.6rem 1.2rem;
font-weight: 600;
}
.btn-primary:hover {
background-color: #0F766E;
border-color: #0F766E;
}
.card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
margin-bottom: 1.5rem;
}
.stat-card {
background: linear-gradient(135deg, white 0%, #F1F5F9 100%);
border-left: 4px solid var(--accent-teal);
}
/* Alert message styling */
.messages {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
min-width: 300px;
}
</style>
{% block extra_css %}{% endblock %}
</head> </head>
<body> <body>
{% block content %}{% endblock %} {% if messages %}
</body> <div class="messages">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show shadow" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
</html> <nav class="navbar navbar-expand-lg sticky-top">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="{% url 'home' %}">
<span style="color: var(--accent-teal);">Host</span>Loyalty
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'home' %}active{% endif %}" href="{% url 'home' %}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'guest_list' or request.resolver_match.url_name == 'import_guests' %}active{% endif %}" href="{% url 'guest_list' %}">Guests</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'campaign_list' %}active{% endif %}" href="{% url 'campaign_list' %}">Campaigns</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/" target="_blank">Admin</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="py-5">
<div class="container">
{% block content %}{% endblock %}
</div>
</main>
<footer class="py-4 mt-auto border-top bg-white">
<div class="container text-center text-muted">
<small>&copy; {% now "Y" %} Host Loyalty CRM. All rights reserved.</small>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col-md-8">
<h1>Email Campaigns</h1>
<p class="text-muted">Re-engage your guests with personalized offers and updates.</p>
</div>
<div class="col-md-4 text-md-end">
<a href="/admin/core/campaign/add/" class="btn btn-primary">+ Create Campaign</a>
</div>
</div>
{% if messages %}
<div class="row">
<div class="col-12">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="row g-4">
{% for campaign in campaigns %}
<div class="col-md-6">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="mb-1">{{ campaign.title }}</h5>
<small class="text-muted">Subject: {{ campaign.subject }}</small>
</div>
{% if campaign.status == 'sent' %}
<span class="badge bg-success">Sent</span>
{% else %}
<span class="badge bg-warning text-dark">Draft</span>
{% endif %}
</div>
<p class="text-truncate text-muted small mb-4">{{ campaign.body|striptags }}</p>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{% if campaign.status == 'sent' %}
Sent on {{ campaign.sent_at|date:"M d, Y" }}
{% else %}
Created {{ campaign.created_at|date:"M d, Y" }}
{% endif %}
</small>
<div class="btn-group">
<a href="/admin/core/campaign/{{ campaign.id }}/change/" class="btn btn-sm btn-outline-secondary">Edit</a>
{% if campaign.status == 'draft' %}
<a href="{% url 'send_campaign' campaign.id %}" class="btn btn-sm btn-primary" onclick="return confirm('Are you sure you want to send this campaign to all guests?')">Send Now</a>
{% endif %}
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12">
<div class="card p-5 text-center text-muted">
<p>No campaigns yet. Launch your first loyalty offer today!</p>
<div>
<a href="/admin/core/campaign/add/" class="btn btn-primary">+ Create First Campaign</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col-md-7">
<h1>Guests</h1>
<p class="text-muted">Manage your guest relationships and view their stay history.</p>
</div>
<div class="col-md-5 text-md-end">
<a href="{% url 'import_guests' %}" class="btn btn-outline-teal me-2">
<i class="bi bi-file-earmark-arrow-up me-1"></i> Import CSV
</a>
<a href="/admin/core/guest/add/" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i> New Guest
</a>
</div>
</div>
<div class="card p-4 shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Guest Details</th>
<th>Email</th>
<th>Stays</th>
<th>Joined</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for guest in guests %}
<tr>
<td>
<div class="fw-bold text-dark">{{ guest }}</div>
<small class="text-muted">{{ guest.phone|default:"No phone" }}</small>
</td>
<td>{{ guest.email }}</td>
<td>
<span class="badge bg-light text-teal border border-teal-subtle">
{{ guest.stay_count }} stay{{ guest.stay_count|pluralize }}
</span>
</td>
<td>{{ guest.created_at|date:"M d, Y" }}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-teal">Send Offer</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5">
<div class="text-muted mb-3">No guests found.</div>
<a href="{% url 'import_guests' %}" class="btn btn-sm btn-teal">Import your first guests</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<style>
.btn-outline-teal {
color: var(--accent-teal);
border-color: var(--accent-teal);
}
.btn-outline-teal:hover {
background-color: var(--accent-teal);
color: white;
}
.btn-teal {
background-color: var(--accent-teal);
color: white;
}
.btn-teal:hover {
background-color: #0b7a6f;
color: white;
}
.text-teal {
color: var(--accent-teal) !important;
}
.border-teal-subtle {
border-color: #99f6e4 !important;
}
</style>
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block content %}
<div class="row mb-4">
<div class="col-md-8">
<h1>Import Guests</h1>
<p class="text-muted">Upload a CSV file to bulk add or update your guest list.</p>
</div>
<div class="col-md-4 text-md-end">
<a href="{% url 'guest_list' %}" class="btn btn-outline-secondary">Back to List</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card p-4">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-4">
<label for="csv_file" class="form-label fw-bold">Select CSV File</label>
<input type="file" class="form-control" id="csv_file" name="csv_file" accept=".csv" required>
<div class="form-text mt-2">Maximum file size: 5MB</div>
</div>
<button type="submit" class="btn btn-teal w-100 py-2">
<i class="bi bi-upload me-2"></i> Start Import
</button>
</form>
</div>
</div>
<div class="col-md-6">
<div class="card p-4 border-0 bg-light">
<h5 class="fw-bold mb-3">CSV Format Requirements</h5>
<p>Your CSV file should include a header row with the following column names:</p>
<ul class="list-group list-group-flush bg-transparent">
<li class="list-group-item bg-transparent px-0 border-0">
<code class="fw-bold">first_name</code> (Required)
</li>
<li class="list-group-item bg-transparent px-0 border-0">
<code class="fw-bold">last_name</code> (Required)
</li>
<li class="list-group-item bg-transparent px-0 border-0">
<code class="fw-bold">email</code> (Required, used for matching)
</li>
<li class="list-group-item bg-transparent px-0 border-0">
<code class="fw-bold">phone</code> (Optional)
</li>
</ul>
<div class="mt-4 p-3 bg-white rounded border">
<p class="small text-muted mb-2">Example Content:</p>
<code class="small d-block">first_name,last_name,email,phone</code>
<code class="small d-block">Jane,Doe,jane@example.com,123-456-7890</code>
<code class="small d-block">John,Smith,john@example.com,</code>
</div>
</div>
</div>
</div>
<style>
.btn-teal {
background-color: var(--accent-teal);
color: white;
}
.btn-teal:hover {
background-color: #0b7a6f;
color: white;
}
</style>
{% endblock %}

View File

@ -1,145 +1,120 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %} {% block content %}
<div class="row mb-4 align-items-center">
<div class="col-md-8">
<h1 class="display-5">Welcome back, Host</h1>
<p class="lead text-muted">Here's what's happening with your properties and loyalty program.</p>
</div>
<div class="col-md-4 text-md-end">
<a href="/admin/core/guest/add/" class="btn btn-primary">+ Add New Guest</a>
</div>
</div>
<!-- Stats Row -->
<div class="row g-4 mb-5">
<div class="col-md-4">
<div class="card stat-card p-4">
<h6 class="text-uppercase text-muted fw-bold small">Total Guests</h6>
<h2 class="mb-0">{{ total_guests }}</h2>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card p-4">
<h6 class="text-uppercase text-muted fw-bold small">Properties</h6>
<h2 class="mb-0">{{ total_properties }}</h2>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card p-4">
<h6 class="text-uppercase text-muted fw-bold small">Total Stays</h6>
<h2 class="mb-0">{{ total_stays }}</h2>
</div>
</div>
</div>
<div class="row g-4">
<!-- Recent Stays -->
<div class="col-lg-8">
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Recent Stays</h4>
<a href="{% url 'guest_list' %}" class="btn btn-sm btn-outline-secondary">View All</a>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Guest</th>
<th>Property</th>
<th>Check-in</th>
<th>Nights</th>
</tr>
</thead>
<tbody>
{% for stay in recent_stays %}
<tr>
<td>
<div class="fw-bold">{{ stay.guest }}</div>
<small class="text-muted">{{ stay.guest.email }}</small>
</td>
<td>{{ stay.property.name }}</td>
<td>{{ stay.check_in|date:"M d, Y" }}</td>
<td><span class="badge bg-teal-soft text-dark border">{{ stay.total_nights }}</span></td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center py-4">No stays recorded yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Recent Guests / Quick Actions -->
<div class="col-lg-4">
<div class="card p-4 mb-4">
<h4 class="mb-4">Newest Guests</h4>
<ul class="list-unstyled">
{% for guest in recent_guests %}
<li class="d-flex align-items-center mb-3">
<div class="avatar-circle me-3">{{ guest.first_name|first }}{{ guest.last_name|first }}</div>
<div>
<div class="fw-bold">{{ guest }}</div>
<small class="text-muted">Joined {{ guest.created_at|date:"M d" }}</small>
</div>
</li>
{% empty %}
<li class="text-muted">No guests yet.</li>
{% endfor %}
</ul>
</div>
<div class="card p-4 bg-primary text-white border-0">
<h5 class="mb-3">Launch a Campaign</h5>
<p class="small opacity-75">Ready to reach out? Send a special offer to your past guests to boost repeat bookings.</p>
<a href="{% url 'campaign_list' %}" class="btn btn-light btn-sm fw-bold">Create Campaign</a>
</div>
</div>
</div>
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style> <style>
:root { .bg-teal-soft {
--bg-color-start: #6a11cb; background-color: #F0FDFA;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
} }
.avatar-circle {
100% { width: 40px;
background-position: 100% 100%; height: 40px;
background-color: var(--accent-teal);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.8rem;
} }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %}
<main>
<div class="card">
<h1>Analyzing your requirements and generating your app…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div>
</main>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
</footer>
{% endblock %}

View File

@ -1,7 +1,10 @@
from django.urls import path from django.urls import path
from . import views
from .views import home
urlpatterns = [ urlpatterns = [
path("", home, name="home"), path('', views.home, name='home'),
] path('guests/', views.guest_list, name='guest_list'),
path('guests/import/', views.import_guests, name='import_guests'),
path('campaigns/', views.campaign_list, name='campaign_list'),
path('campaigns/send/<int:pk>/', views.send_campaign, name='send_campaign'),
]

View File

@ -1,25 +1,108 @@
import os import csv
import platform import io
from django.shortcuts import render, redirect, get_object_or_404
from django import get_version as django_version from django.db.models import Count, Sum
from django.shortcuts import render
from django.utils import timezone from django.utils import timezone
from django.contrib import messages
from .models import Property, Guest, Stay, Campaign
from .mail import send_campaign_email
def home(request): def home(request):
"""Render the landing screen with loader and environment details.""" """Host Dashboard"""
host_name = request.get_host().lower() total_guests = Guest.objects.count()
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" total_properties = Property.objects.count()
now = timezone.now() total_stays = Stay.objects.count()
recent_stays = Stay.objects.select_related('guest', 'property').order_by('-check_in')[:5]
recent_guests = Guest.objects.order_by('-created_at')[:5]
context = { context = {
"project_name": "New Style", 'total_guests': total_guests,
"agent_brand": agent_brand, 'total_properties': total_properties,
"django_version": django_version(), 'total_stays': total_stays,
"python_version": platform.python_version(), 'recent_stays': recent_stays,
"current_time": now, 'recent_guests': recent_guests,
"host_name": host_name, 'project_name': 'Host Loyalty CRM'
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, "core/index.html", context) return render(request, "core/index.html", context)
def guest_list(request):
"""List of all guests"""
guests = Guest.objects.annotate(stay_count=Count('stays')).order_by('-created_at')
return render(request, "core/guest_list.html", {'guests': guests})
def import_guests(request):
"""Import guests from a CSV file"""
if request.method == 'POST' and request.FILES.get('csv_file'):
csv_file = request.FILES['csv_file']
if not csv_file.name.endswith('.csv'):
messages.error(request, 'Please upload a CSV file.')
return redirect('import_guests')
try:
decoded_file = csv_file.read().decode('utf-8')
io_string = io.StringIO(decoded_file)
reader = csv.DictReader(io_string)
# Simple column mapping/validation
required_cols = {'first_name', 'last_name', 'email'}
if not required_cols.issubset(set(reader.fieldnames or [])):
messages.error(request, f'CSV must contain columns: {", ".join(required_cols)}')
return redirect('import_guests')
created_count = 0
updated_count = 0
for row in reader:
guest, created = Guest.objects.update_or_create(
email=row['email'].strip().lower(),
defaults={
'first_name': row['first_name'].strip(),
'last_name': row['last_name'].strip(),
'phone': row.get('phone', '').strip(),
}
)
if created:
created_count += 1
else:
updated_count += 1
messages.success(request, f'Successfully imported {created_count} new guests and updated {updated_count}.')
return redirect('guest_list')
except Exception as e:
messages.error(request, f'Error processing file: {str(e)}')
return redirect('import_guests')
return render(request, "core/import_guests.html")
def campaign_list(request):
"""List of email campaigns"""
campaigns = Campaign.objects.all().order_by('-created_at')
return render(request, "core/campaign_list.html", {'campaigns': campaigns})
def send_campaign(request, pk):
"""Send a campaign to all guests"""
campaign = get_object_or_404(Campaign, pk=pk)
if campaign.status == 'sent':
messages.warning(request, "This campaign has already been sent.")
return redirect('campaign_list')
guests = Guest.objects.all()
if not guests:
messages.error(request, "No guests found to send the campaign to.")
return redirect('campaign_list')
success_count, fail_count = send_campaign_email(campaign, guests)
campaign.status = 'sent'
campaign.sent_at = timezone.now()
campaign.save()
if success_count > 0:
messages.success(request, f"Campaign sent successfully to {success_count} guests.")
if fail_count > 0:
messages.error(request, f"Failed to send to {fail_count} guests.")
return redirect('campaign_list')