Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6a5b3caf |
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.
BIN
core/__pycache__/mail.cpython-311.pyc
Normal file
BIN
core/__pycache__/mail.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
50
core/mail.py
Normal 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
|
||||||
0
core/management/__init__.py
Normal file
0
core/management/__init__.py
Normal file
BIN
core/management/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/management/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
0
core/management/commands/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
BIN
core/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/management/commands/__pycache__/seed_data.cpython-311.pyc
Normal file
BIN
core/management/commands/__pycache__/seed_data.cpython-311.pyc
Normal file
Binary file not shown.
47
core/management/commands/seed_data.py
Normal file
47
core/management/commands/seed_data.py
Normal 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'))
|
||||||
59
core/migrations/0001_initial.py
Normal file
59
core/migrations/0001_initial.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -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
|
||||||
@ -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 %}
|
||||||
|
|
||||||
|
<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 %}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
71
core/templates/core/campaign_list.html
Normal file
71
core/templates/core/campaign_list.html
Normal 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 %}
|
||||||
86
core/templates/core/guest_list.html
Normal file
86
core/templates/core/guest_list.html
Normal 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 %}
|
||||||
71
core/templates/core/import_guests.html
Normal file
71
core/templates/core/import_guests.html
Normal 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 %}
|
||||||
@ -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 %}
|
|
||||||
@ -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'),
|
||||||
]
|
]
|
||||||
119
core/views.py
119
core/views.py
@ -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')
|
||||||
Loading…
x
Reference in New Issue
Block a user