Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,20 +1,3 @@
|
||||
from django.contrib import admin
|
||||
from .models import Property, Guest, Stay, Campaign
|
||||
|
||||
@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')
|
||||
# Register your models here.
|
||||
|
||||
50
core/mail.py
50
core/mail.py
@ -1,50 +0,0 @@
|
||||
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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,47 +0,0 @@
|
||||
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'))
|
||||
@ -1,59 +0,0 @@
|
||||
# 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -1,52 +1,3 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
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
|
||||
# Create your models here.
|
||||
|
||||
@ -1,151 +1,25 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ project_name }}{% endblock %}</title>
|
||||
<meta name="description" content="Airbnb Host Loyalty CRM - Manage guests, properties and campaigns.">
|
||||
|
||||
<!-- Fonts -->
|
||||
<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;500;600&family=Lexend:wght@600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap 5 CSS CDN -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- 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 %}
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% if messages %}
|
||||
<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 %}
|
||||
|
||||
<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>© {% 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 %}
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,86 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,71 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,120 +1,145 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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 title %}{{ project_name }}{% endblock %}
|
||||
|
||||
{% 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>
|
||||
.bg-teal-soft {
|
||||
background-color: #F0FDFA;
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--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 {
|
||||
width: 40px;
|
||||
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;
|
||||
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
{% 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 %}
|
||||
11
core/urls.py
11
core/urls.py
@ -1,10 +1,7 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
from .views import home
|
||||
|
||||
urlpatterns = [
|
||||
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'),
|
||||
]
|
||||
path("", home, name="home"),
|
||||
]
|
||||
|
||||
121
core/views.py
121
core/views.py
@ -1,108 +1,25 @@
|
||||
import csv
|
||||
import io
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.db.models import Count, Sum
|
||||
import os
|
||||
import platform
|
||||
|
||||
from django import get_version as django_version
|
||||
from django.shortcuts import render
|
||||
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):
|
||||
"""Host Dashboard"""
|
||||
total_guests = Guest.objects.count()
|
||||
total_properties = Property.objects.count()
|
||||
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]
|
||||
|
||||
"""Render the landing screen with loader and environment details."""
|
||||
host_name = request.get_host().lower()
|
||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||
now = timezone.now()
|
||||
|
||||
context = {
|
||||
'total_guests': total_guests,
|
||||
'total_properties': total_properties,
|
||||
'total_stays': total_stays,
|
||||
'recent_stays': recent_stays,
|
||||
'recent_guests': recent_guests,
|
||||
'project_name': 'Host Loyalty CRM'
|
||||
"project_name": "New Style",
|
||||
"agent_brand": agent_brand,
|
||||
"django_version": django_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"current_time": now,
|
||||
"host_name": host_name,
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
}
|
||||
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')
|
||||
Loading…
x
Reference in New Issue
Block a user