Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
03bf5cace7 1.1 2026-02-22 21:23:39 +00:00
29 changed files with 531 additions and 193 deletions

View File

@ -133,7 +133,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = 'uk'
TIME_ZONE = 'UTC'

View File

@ -1,3 +1,18 @@
from django.contrib import admin
from .models import Product, Variation
# Register your models here.
class VariationInline(admin.TabularInline):
model = Variation
extra = 0
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ('name', 'sku', 'price', 'stock_status', 'total_sales')
search_fields = ('name', 'sku', 'wp_id')
list_filter = ('stock_status',)
inlines = [VariationInline]
@admin.register(Variation)
class VariationAdmin(admin.ModelAdmin):
list_display = ('product', 'size', 'price', 'stock_quantity')
search_fields = ('product__name', 'size')

View File

Binary file not shown.

View File

View File

@ -0,0 +1,93 @@
import requests
from django.core.management.base import BaseCommand
from core.models import Product, Variation
from django.conf import settings
from decimal import Decimal
class Command(BaseCommand):
help = "Sync products and variations from WooCommerce REST API"
CK = "ck_e4107000984fd8752473cdd7974e41d227705215"
CS = "cs_c200854aaf7a9798feae9c0844219a36cb5e46bd"
BASE_URL = "https://theothers.com.ua/wp-json/wc/v3/"
def handle(self, *args, **options):
self.stdout.write("Starting sync from WooCommerce...")
# 1. Fetch Products
page = 1
while True:
response = requests.get(
f"{self.BASE_URL}products",
auth=(self.CK, self.CS),
params={"page": page, "per_page": 100}
)
if response.status_code != 200:
self.stderr.write(f"Error fetching products: {response.text}")
break
products_data = response.json()
if not products_data:
break
for p in products_data:
# Map product fields
wp_id = str(p.get("id"))
name = p.get("name")
sku = p.get("sku")
price = Decimal(p.get("price") or 0)
stock_status = p.get("stock_status", "outofstock")
total_sales = p.get("total_sales", 0)
image_url = p.get("images")[0]["src"] if p.get("images") else None
product, created = Product.objects.update_or_create(
wp_id=wp_id,
defaults={
"name": name,
"sku": sku,
"price": price,
"stock_status": stock_status,
"total_sales": total_sales,
"image": image_url,
}
)
# 2. Fetch Variations for variable products
if p.get("type") == "variable":
v_response = requests.get(
f"{self.BASE_URL}products/{wp_id}/variations",
auth=(self.CK, self.CS),
params={"per_page": 100}
)
if v_response.status_code == 200:
variations_data = v_response.json()
# Clear old variations to keep it clean (optional, but safer for simple sync)
# Variation.objects.filter(product=product).delete()
for v in variations_data:
# Use attributes to find size
attributes = v.get("attributes", [])
size = "Default"
for attr in attributes:
if attr.get("name").lower() in ["size", "розмір"]:
size = attr.get("option")
break
v_price = Decimal(v.get("price") or 0)
v_stock = v.get("stock_quantity") or 0
Variation.objects.update_or_create(
product=product,
size=size,
defaults={
"price": v_price,
"stock_quantity": v_stock,
}
)
self.stdout.write(f"Processed page {page}")
page += 1
self.stdout.write(self.style.SUCCESS("Successfully synced WooCommerce data!"))

View File

@ -0,0 +1,49 @@
# Generated by Django 5.2.7 on 2026-02-22 20:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('wp_id', models.CharField(max_length=50, unique=True, verbose_name='ID WordPress')),
('image', models.URLField(blank=True, max_length=500, null=True, verbose_name='Фото')),
('name', models.CharField(max_length=255, verbose_name='Назва товару')),
('sku', models.CharField(blank=True, max_length=100, null=True, verbose_name='Артикул (SKU)')),
('price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Ціна (грн)')),
('stock_status', models.CharField(choices=[('instock', 'В наявності'), ('outofstock', 'Немає в наявності')], max_length=20, verbose_name='Статус')),
('total_sales', models.IntegerField(default=0, verbose_name='Продано')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Товар',
'verbose_name_plural': 'Товари',
'ordering': ['-total_sales'],
},
),
migrations.CreateModel(
name='Variation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('size', models.CharField(max_length=50, verbose_name='Розмір')),
('price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Ціна варіації')),
('stock_quantity', models.IntegerField(default=0, verbose_name='Залишок на складі')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variations', to='core.product', verbose_name='Основний товар')),
],
options={
'verbose_name': 'Варіація',
'verbose_name_plural': 'Варіації',
},
),
]

View File

@ -1,3 +1,39 @@
from django.db import models
# Create your models here.
class Product(models.Model):
STOCK_STATUS_CHOICES = [
('instock', 'В наявності'),
('outofstock', 'Немає в наявності'),
]
wp_id = models.CharField(max_length=50, unique=True, verbose_name="ID WordPress")
image = models.URLField(max_length=500, blank=True, null=True, verbose_name="Фото")
name = models.CharField(max_length=255, verbose_name="Назва товару")
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул (SKU)")
price = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Ціна (грн)")
stock_status = models.CharField(max_length=20, choices=STOCK_STATUS_CHOICES, verbose_name="Статус")
total_sales = models.IntegerField(default=0, verbose_name="Продано")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Meta:
verbose_name = "Товар"
verbose_name_plural = "Товари"
ordering = ['-total_sales']
class Variation(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='variations', verbose_name="Основний товар")
size = models.CharField(max_length=50, verbose_name="Розмір")
price = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Ціна варіації")
stock_quantity = models.IntegerField(default=0, verbose_name="Залишок на складі")
def __str__(self):
return f"{self.product.name} - {self.size}"
class Meta:
verbose_name = "Варіація"
verbose_name_plural = "Варіації"

View File

@ -1,25 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<html lang="uk">
<head>
<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 %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Адмін-панель WooCommerce{% endblock %}</title>
<!-- Inter Font -->
<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@300;400;500;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">
{% load static %}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
<nav class="navbar navbar-expand-lg sticky-top">
<div class="container-fluid">
<a class="navbar-brand fw-bold" href="{% url 'core:index' %}" style="color: var(--apple-text);">
<span style="color: var(--apple-accent);">The</span>Others Admin
</a>
<div class="d-flex align-items-center">
<a href="/admin/" class="text-decoration-none text-muted small me-3">Django Admin</a>
</div>
</div>
</nav>
<main class="container my-4">
{% block content %}{% endblock %}
</main>
<!-- Bootstrap 5 JS Bundle CDN -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -1,145 +1,77 @@
{% extends "base.html" %}
{% extends 'base.html' %}
{% load static %}
{% 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>
: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%;
}
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 title %}Товари — Адмін-панель{% 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 class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 fw-bold m-0">Товари</h1>
<div class="d-flex align-items-center gap-2">
<form action="{% url 'core:index' %}" method="get" class="d-flex gap-2">
<input type="text" name="q" value="{{ query|default:'' }}" class="form-control search-input" placeholder="Пошук за назвою або SKU..." style="min-width: 300px;">
<button type="submit" class="btn btn-primary d-none">Пошук</button>
</form>
</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 %}
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 80px;">Фото</th>
<th>Назва товару</th>
<th>Артикул (SKU)</th>
<th>Ціна (грн)</th>
<th>Статус</th>
<th class="text-center">Продано</th>
<th style="width: 100px;">Дії</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr>
<td>
{% if p.image %}
<img src="{{ p.image }}" alt="{{ p.name }}" class="product-img">
{% else %}
<div class="product-img d-flex align-items-center justify-content-center text-muted small">N/A</div>
{% endif %}
</td>
<td>
<div class="fw-bold">{{ p.name }}</div>
<div class="small text-muted">WP ID: {{ p.wp_id }}</div>
</td>
<td><code>{{ p.sku|default:"-" }}</code></td>
<td>
<div class="fw-bold">{{ p.price }} ₴</div>
</td>
<td>
<span class="status-{{ p.stock_status }}">
{% if p.stock_status == 'instock' %}
В наявності
{% else %}
Немає
{% endif %}
</span>
</td>
<td class="text-center">
<span class="badge rounded-pill bg-light text-dark px-3">{{ p.total_sales }}</span>
</td>
<td>
<a href="{% url 'core:product_detail' p.pk %}" class="btn btn-sm btn-outline-secondary rounded-3 px-3">Деталі</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-5">
<div class="text-muted">Товарів не знайдено.</div>
<div class="small mt-2">Спробуйте інший запит або запустіть синхронізацію.</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ product.name }} — Деталі товару{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{% url 'core:index' %}" class="text-decoration-none text-muted small d-flex align-items-center mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-left me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
</svg>
Назад до списку
</a>
<div class="d-flex align-items-center justify-content-between">
<h1 class="h3 fw-bold m-0">{{ product.name }}</h1>
<div class="text-muted small">WordPress ID: {{ product.wp_id }}</div>
</div>
</div>
<div class="row g-4">
<!-- Left Column: Product Info -->
<div class="col-lg-4">
<div class="card mb-4 border-0">
{% if product.image %}
<img src="{{ product.image }}" alt="{{ product.name }}" class="card-img-top img-fluid" style="border-radius: 12px 12px 0 0;">
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light text-muted py-5" style="border-radius: 12px 12px 0 0;">N/A</div>
{% endif %}
<div class="p-4">
<div class="mb-3">
<div class="text-muted small mb-1">Ціна</div>
<div class="h4 fw-bold mb-0 text-primary">{{ product.price }} ₴</div>
</div>
<div class="mb-3">
<div class="text-muted small mb-1">Артикул (SKU)</div>
<div class="fw-medium">{{ product.sku|default:"-" }}</div>
</div>
<div class="mb-3">
<div class="text-muted small mb-1">Статус</div>
<span class="status-{{ product.stock_status }} fw-bold">
{% if product.stock_status == 'instock' %}
В наявності
{% else %}
Немає в наявності
{% endif %}
</span>
</div>
<div class="mb-0">
<div class="text-muted small mb-1">Усього продано</div>
<div class="badge bg-light text-dark px-3 py-2 border fw-medium">{{ product.total_sales }}</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Variations Table -->
<div class="col-lg-8">
<div class="card">
<div class="card-header bg-white p-4 border-0 pb-0">
<h2 class="h5 fw-bold m-0">Варіації (Розміри)</h2>
</div>
<div class="p-4">
<div class="table-responsive">
<table class="table border rounded">
<thead>
<tr class="bg-light">
<th class="py-3 px-4 text-uppercase small text-muted">Розмір</th>
<th class="py-3 px-4 text-uppercase small text-muted text-end">Ціна варіації</th>
<th class="py-3 px-4 text-uppercase small text-muted text-end">Залишок на складі</th>
</tr>
</thead>
<tbody>
{% for v in variations %}
<tr>
<td class="py-3 px-4 fw-medium">{{ v.size }}</td>
<td class="py-3 px-4 text-end fw-bold text-primary">{{ v.price }} ₴</td>
<td class="py-3 px-4 text-end">
<span class="badge rounded-pill {% if v.stock_quantity > 0 %}bg-success-subtle text-success{% else %}bg-danger-subtle text-danger{% endif %} px-3">
{{ v.stock_quantity }}
</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center py-4 text-muted small">Варіацій для цього товару не знайдено.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,7 +1,9 @@
from django.urls import path
from . import views
from .views import home
app_name = 'core'
urlpatterns = [
path("", home, name="home"),
]
path('', views.product_list, name='index'),
path('product/<int:pk>/', views.product_detail, name='product_detail'),
]

View File

@ -1,25 +1,26 @@
import os
import platform
from django.shortcuts import render, get_object_or_404
from .models import Product, Variation
from django.db.models import Q
from django import get_version as django_version
from django.shortcuts import render
from django.utils import timezone
def product_list(request):
query = request.GET.get('q')
products = Product.objects.all()
if query:
products = products.filter(
Q(name__icontains=query) | Q(sku__icontains=query)
)
return render(request, 'core/index.html', {
'products': products,
'query': query
})
def home(request):
"""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 = {
"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 product_detail(request, pk):
product = get_object_or_404(Product, pk=pk)
variations = product.variations.all()
return render(request, 'core/product_detail.html', {
'product': product,
'variations': variations
})

View File

@ -1,4 +1,101 @@
/* Custom styles for the application */
body {
font-family: system-ui, -apple-system, sans-serif;
:root {
--apple-bg: #FBFBFB;
--apple-card: #FFFFFF;
--apple-text: #1D1D1F;
--apple-text-muted: #86868B;
--apple-accent: #0071E3;
--apple-border: rgba(0,0,0,0.1);
}
body {
background-color: var(--apple-bg);
color: var(--apple-text);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.navbar {
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--apple-border);
padding: 0.8rem 1rem;
}
.card {
background: var(--apple-card);
border: 1px solid var(--apple-border);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
overflow: hidden;
}
.card:hover {
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.table {
margin-bottom: 0;
}
.table thead th {
background: #F5F5F7;
border-bottom: 1px solid var(--apple-border);
color: var(--apple-text-muted);
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 12px 16px;
}
.table tbody td {
padding: 16px;
vertical-align: middle;
border-bottom: 1px solid var(--apple-border);
}
.btn-primary {
background-color: var(--apple-accent);
border: none;
border-radius: 8px;
font-weight: 500;
padding: 8px 20px;
}
.btn-primary:hover {
background-color: #0077ED;
}
.product-img {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 8px;
background-color: #eee;
}
.status-instock {
color: #34C759;
font-weight: 600;
}
.status-outofstock {
color: #FF3B30;
font-weight: 600;
}
.search-input {
border-radius: 10px;
border: 1px solid var(--apple-border);
background-color: #F5F5F7;
padding: 10px 16px;
font-size: 0.9rem;
}
.search-input:focus {
background-color: #fff;
box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.1);
border-color: var(--apple-accent);
outline: none;
}