Add price check

This commit is contained in:
2026-05-11 21:28:59 +10:00
parent 136df1ce51
commit 1b04d500d1
29 changed files with 1258 additions and 0 deletions
+2
View File
@@ -42,6 +42,7 @@ COPY invest/ ./invest/
COPY netscan/ ./netscan/
COPY nginxmon/ ./nginxmon/
COPY routermon/ ./routermon/
COPY pricemon/ ./pricemon/
COPY new_theme/ ./new_theme/
COPY templates/ ./templates/
COPY locale/ ./locale/
@@ -146,6 +147,7 @@ COPY --chown=appuser:appuser invest/ ./invest/
COPY --chown=appuser:appuser netscan/ ./netscan/
COPY --chown=appuser:appuser nginxmon/ ./nginxmon/
COPY --chown=appuser:appuser routermon/ ./routermon/
COPY --chown=appuser:appuser pricemon/ ./pricemon/
COPY --chown=appuser:appuser new_theme/ ./new_theme/
COPY --chown=appuser:appuser templates/ ./templates/
COPY --chown=appuser:appuser static/ ./static/
+1
View File
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
'netscan',
'nginxmon',
'routermon',
'pricemon',
]
ROOT_URLCONF = 'core.urls'
+1
View File
@@ -53,6 +53,7 @@ urlpatterns = [
path('ui/netscan/', include('netscan.urls')),
path('ui/nginxmon/', include('nginxmon.urls')),
path('ui/routermon/', include('routermon.urls')),
path('ui/pricemon/', include('pricemon.urls')),
path('ui/files/', include('links.file_urls')),
# Import external image by URL — /import/images/<path:image_url> (also plural alias)
@@ -0,0 +1,24 @@
# Generated by Django 5.2.12 on 2026-05-11 11:17
import netscan.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('links', '0048_remove_knowledge_graph'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='telegram_bot_token',
field=netscan.fields.EncryptedCharField(blank=True, help_text='Global Telegram bot token (from @BotFather). Used by all apps that send Telegram alerts.'),
),
migrations.AddField(
model_name='sitesettings',
name='telegram_chat_id',
field=netscan.fields.EncryptedCharField(blank=True, help_text='Telegram chat ID to receive alerts. Get yours from @userinfobot.'),
),
]
+9
View File
@@ -12,6 +12,7 @@ from datetime import timedelta
from urllib.parse import urlparse
from django.utils.text import slugify
import random
from netscan.fields import EncryptedCharField
logger = logging.getLogger(__name__)
@@ -417,6 +418,14 @@ class SiteSettings(models.Model):
Always use SiteSettings.get() to retrieve the instance.
"""
telegram_bot_token = EncryptedCharField(
blank=True,
help_text=_('Global Telegram bot token (from @BotFather). Used by all apps that send Telegram alerts.'),
)
telegram_chat_id = EncryptedCharField(
blank=True,
help_text=_('Telegram chat ID to receive alerts. Get yours from @userinfobot.'),
)
public_sharing_domain = models.CharField(
_('Public Sharing Domain'),
max_length=255,
+34
View File
@@ -15,6 +15,40 @@
<div class="flex flex-col gap-6">
<!-- Telegram Notifications -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-700 mb-1">{% trans "Telegram Notifications" %}</h2>
<p class="text-sm text-gray-500 mb-4">
{% trans "Global Telegram credentials used by all apps (Price Monitor, NetScan, etc.) to send alerts." %}
{% trans "Create a bot via" %} <a href="https://t.me/BotFather" target="_blank" class="text-blue-500 hover:underline">@BotFather</a>
{% trans "and get your chat ID from" %} <a href="https://t.me/userinfobot" target="_blank" class="text-blue-500 hover:underline">@userinfobot</a>.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Bot Token" %}</label>
<input
type="text"
name="telegram_bot_token"
value="{{ site_settings.telegram_bot_token }}"
placeholder="123456:ABC-DEF1234..."
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<p class="mt-1 text-xs text-gray-400">{% trans "Stored encrypted at rest." %}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Chat ID" %}</label>
<input
type="text"
name="telegram_chat_id"
value="{{ site_settings.telegram_chat_id }}"
placeholder="-100123456789"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<p class="mt-1 text-xs text-gray-400">{% trans "User ID or group chat ID." %}</p>
</div>
</div>
</div>
<!-- Public Sharing Domain -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-700 mb-1">{% trans "Public Sharing Domain" %}</h2>
+2
View File
@@ -578,6 +578,8 @@ class SiteSettingsView(View):
from core.scheduler import scheduler
site_settings = SiteSettings.get()
site_settings.telegram_bot_token = request.POST.get('telegram_bot_token', '').strip()
site_settings.telegram_chat_id = request.POST.get('telegram_chat_id', '').strip()
site_settings.public_sharing_domain = request.POST.get('public_sharing_domain', '').strip()
try:
max_jobs = int(request.POST.get('max_concurrent_screenshot_jobs', 2))
View File
+23
View File
@@ -0,0 +1,23 @@
from django.contrib import admin
from pricemon.models import PriceAlert, PriceSnapshot, PriceWatcher
@admin.register(PriceWatcher)
class PriceWatcherAdmin(admin.ModelAdmin):
list_display = ['name', 'url', 'last_price', 'last_checked_at', 'enabled', 'check_interval_hours']
list_filter = ['enabled', 'check_interval_hours']
search_fields = ['name', 'url']
@admin.register(PriceSnapshot)
class PriceSnapshotAdmin(admin.ModelAdmin):
list_display = ['watcher', 'price', 'raw_text', 'error', 'checked_at']
list_filter = ['watcher']
readonly_fields = ['checked_at']
@admin.register(PriceAlert)
class PriceAlertAdmin(admin.ModelAdmin):
list_display = ['watcher', 'old_price', 'new_price', 'drop_pct', 'dismissed', 'created_at']
list_filter = ['dismissed', 'watcher']
readonly_fields = ['created_at']
+21
View File
@@ -0,0 +1,21 @@
import logging
from django.apps import AppConfig
logger = logging.getLogger(__name__)
class PricemonConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pricemon'
def ready(self):
import pricemon.signals # noqa: F401
try:
from pricemon.tasks import schedule_watcher
from pricemon.models import PriceWatcher
for watcher in PriceWatcher.objects.filter(enabled=True):
schedule_watcher(watcher)
logger.info(f'Scheduled pricemon watcher: {watcher.name}')
except Exception as exc:
logger.warning(f'Could not schedule pricemon watchers on startup: {exc}')
+27
View File
@@ -0,0 +1,27 @@
from django import forms
from pricemon.models import PriceWatcher
_input = 'w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500'
_mono = _input + ' font-mono'
_select = _input
class PriceWatcherForm(forms.ModelForm):
class Meta:
model = PriceWatcher
fields = [
'name', 'url', 'css_selector', 'check_interval_hours',
'alert_threshold_pct', 'enabled',
]
widgets = {
'name': forms.TextInput(attrs={'class': _input}),
'url': forms.URLInput(attrs={'class': _input}),
'css_selector': forms.TextInput(attrs={'class': _mono, 'placeholder': 'e.g. span.price or .product-price'}),
'check_interval_hours': forms.Select(attrs={'class': _select}),
'alert_threshold_pct': forms.NumberInput(attrs={'class': _input, 'step': '0.01', 'min': '0'}),
'enabled': forms.CheckboxInput(attrs={'class': 'h-4 w-4 text-indigo-600 border-gray-300 rounded'}),
}
help_texts = {
'css_selector': 'CSS selector pointing to the element containing the price text.',
'alert_threshold_pct': 'Minimum percentage drop to send an alert. Use 0 to alert on any drop.',
}
+65
View File
@@ -0,0 +1,65 @@
# Generated by Django 5.2.12 on 2026-05-11 10:59
import django.db.models.deletion
import netscan.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='PriceWatcher',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('url', models.URLField(max_length=2000)),
('css_selector', models.CharField(help_text='CSS selector for the price element, e.g. span.price or .product-price', max_length=500)),
('check_interval_hours', models.IntegerField(choices=[(6, 'Every 6 hours'), (12, 'Every 12 hours'), (24, 'Every day'), (48, 'Every 2 days'), (72, 'Every 3 days'), (168, 'Every 7 days')], default=24)),
('alert_threshold_pct', models.DecimalField(decimal_places=2, default=0, help_text='Minimum % price drop to trigger an alert (0 = any drop)', max_digits=5)),
('telegram_bot_token', netscan.fields.EncryptedCharField(blank=True)),
('telegram_chat_id', netscan.fields.EncryptedCharField(blank=True)),
('enabled', models.BooleanField(default=True)),
('last_checked_at', models.DateTimeField(blank=True, null=True)),
('last_price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='PriceSnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('raw_text', models.CharField(blank=True, max_length=500)),
('error', models.CharField(blank=True, max_length=500)),
('checked_at', models.DateTimeField(auto_now_add=True)),
('watcher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='pricemon.pricewatcher')),
],
options={
'ordering': ['-checked_at'],
},
),
migrations.CreateModel(
name='PriceAlert',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('old_price', models.DecimalField(decimal_places=2, max_digits=12)),
('new_price', models.DecimalField(decimal_places=2, max_digits=12)),
('drop_pct', models.DecimalField(decimal_places=2, max_digits=5)),
('created_at', models.DateTimeField(auto_now_add=True)),
('dismissed', models.BooleanField(default=False)),
('watcher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alerts', to='pricemon.pricewatcher')),
],
options={
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.2.12 on 2026-05-11 11:10
import netscan.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pricemon', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PricemonSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('telegram_bot_token', netscan.fields.EncryptedCharField(blank=True)),
('telegram_chat_id', netscan.fields.EncryptedCharField(blank=True)),
],
options={
'verbose_name': 'Pricemon Settings',
'verbose_name_plural': 'Pricemon Settings',
},
),
migrations.RemoveField(
model_name='pricewatcher',
name='telegram_bot_token',
),
migrations.RemoveField(
model_name='pricewatcher',
name='telegram_chat_id',
),
]
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pricemon', '0002_pricemonsettings_and_more'),
]
operations = [
migrations.DeleteModel(
name='PricemonSettings',
),
]
View File
+63
View File
@@ -0,0 +1,63 @@
from django.db import models
class PriceWatcher(models.Model):
INTERVAL_CHOICES = [
(6, 'Every 6 hours'),
(12, 'Every 12 hours'),
(24, 'Every day'),
(48, 'Every 2 days'),
(72, 'Every 3 days'),
(168, 'Every 7 days'),
]
name = models.CharField(max_length=200)
url = models.URLField(max_length=2000)
css_selector = models.CharField(
max_length=500,
help_text='CSS selector for the price element, e.g. span.price or .product-price',
)
check_interval_hours = models.IntegerField(choices=INTERVAL_CHOICES, default=24)
alert_threshold_pct = models.DecimalField(
max_digits=5, decimal_places=2, default=0,
help_text='Minimum % price drop to trigger an alert (0 = any drop)',
)
enabled = models.BooleanField(default=True)
last_checked_at = models.DateTimeField(null=True, blank=True)
last_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class PriceSnapshot(models.Model):
watcher = models.ForeignKey(PriceWatcher, on_delete=models.CASCADE, related_name='snapshots')
price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
raw_text = models.CharField(max_length=500, blank=True)
error = models.CharField(max_length=500, blank=True)
checked_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-checked_at']
def __str__(self):
return f'{self.watcher.name} @ {self.checked_at:%Y-%m-%d %H:%M}'
class PriceAlert(models.Model):
watcher = models.ForeignKey(PriceWatcher, on_delete=models.CASCADE, related_name='alerts')
old_price = models.DecimalField(max_digits=12, decimal_places=2)
new_price = models.DecimalField(max_digits=12, decimal_places=2)
drop_pct = models.DecimalField(max_digits=5, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
dismissed = models.BooleanField(default=False)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f'{self.watcher.name}: ${self.old_price} → ${self.new_price}'
+57
View File
@@ -0,0 +1,57 @@
import logging
import requests
logger = logging.getLogger(__name__)
def _get_telegram_credentials():
from links.models import SiteSettings
cfg = SiteSettings.get()
return cfg.telegram_bot_token, cfg.telegram_chat_id
def notify_telegram(watcher, alert) -> None:
token, chat_id = _get_telegram_credentials()
if not token or not chat_id:
logger.warning('Pricemon: no global Telegram credentials configured, skipping alert')
return
text = (
f'\U0001f4b0 *Price Drop Alert\\!*\n'
f'*{_esc(watcher.name)}*\n'
f'${alert.old_price} → *${alert.new_price}* \\({float(alert.drop_pct):.1f}% off\\)\n'
f'[View Product]({watcher.url})'
)
try:
resp = requests.post(
f'https://api.telegram.org/bot{token}/sendMessage',
json={'chat_id': chat_id, 'text': text, 'parse_mode': 'MarkdownV2'},
timeout=10,
)
resp.raise_for_status()
logger.info(f'Telegram alert sent for watcher {watcher.pk}')
except Exception as exc:
logger.error(f'Failed to send Telegram alert for watcher {watcher.pk}: {exc}')
def send_test_telegram() -> dict:
token, chat_id = _get_telegram_credentials()
if not token or not chat_id:
return {'ok': False, 'error': 'No Telegram credentials in global Settings.'}
try:
resp = requests.post(
f'https://api.telegram.org/bot{token}/sendMessage',
json={'chat_id': chat_id, 'text': '\u2705 *PriceMon test* — notifications are working\\!', 'parse_mode': 'MarkdownV2'},
timeout=10,
)
resp.raise_for_status()
return {'ok': True}
except Exception as exc:
return {'ok': False, 'error': str(exc)}
def _esc(text: str) -> str:
for ch in r'\_*[]()~`>#+-=|{}.!':
text = text.replace(ch, f'\\{ch}')
return text
+80
View File
@@ -0,0 +1,80 @@
import logging
import re
from decimal import Decimal, InvalidOperation
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
_HEADERS = {
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/124.0.0.0 Safari/537.36'
),
'Accept-Language': 'en-AU,en;q=0.9',
}
def parse_price(text: str) -> Decimal | None:
"""Extract the first numeric price from a string."""
if not text:
return None
cleaned = text.strip().replace(',', '')
match = re.search(r'\d+\.?\d*', cleaned)
if not match:
return None
try:
return Decimal(match.group())
except InvalidOperation:
return None
def _fetch_with_requests(url: str, css_selector: str) -> tuple[str, str | None]:
"""Returns (raw_text, error). raw_text is empty string on failure."""
try:
resp = requests.get(url, headers=_HEADERS, timeout=15)
resp.raise_for_status()
soup = BeautifulSoup(resp.content, 'html.parser')
el = soup.select_one(css_selector)
if el:
return el.get_text(strip=True), None
return '', f'Selector "{css_selector}" matched no element'
except Exception as exc:
return '', str(exc)
def _fetch_with_playwright(url: str, css_selector: str) -> tuple[str, str | None]:
"""Fallback for JS-rendered pages."""
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(extra_http_headers={'Accept-Language': 'en-AU,en;q=0.9'})
page.goto(url, wait_until='domcontentloaded', timeout=30000)
page.wait_for_timeout(2000)
el = page.query_selector(css_selector)
text = el.inner_text() if el else ''
browser.close()
if not text:
return '', f'Selector "{css_selector}" matched no element (playwright)'
return text.strip(), None
except Exception as exc:
return '', f'Playwright error: {exc}'
def fetch_price(watcher) -> tuple[Decimal | None, str, str | None]:
"""
Returns (price, raw_text, error).
Tries requests first; falls back to Playwright if the element isn't found.
"""
raw_text, error = _fetch_with_requests(watcher.url, watcher.css_selector)
if not raw_text and not error:
logger.info(f'Falling back to Playwright for watcher {watcher.pk}')
raw_text, error = _fetch_with_playwright(watcher.url, watcher.css_selector)
price = parse_price(raw_text) if raw_text else None
logger.info(f'Watcher {watcher.pk}: raw="{raw_text}" price={price} error={error}')
return price, raw_text, error
+17
View File
@@ -0,0 +1,17 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
@receiver(post_save, sender='pricemon.PriceWatcher')
def reschedule_on_save(sender, instance, **kwargs):
from pricemon.tasks import schedule_watcher, unschedule_watcher
if instance.enabled:
schedule_watcher(instance)
else:
unschedule_watcher(instance)
@receiver(post_delete, sender='pricemon.PriceWatcher')
def unschedule_on_delete(sender, instance, **kwargs):
from pricemon.tasks import unschedule_watcher
unschedule_watcher(instance)
+67
View File
@@ -0,0 +1,67 @@
import logging
from apscheduler.triggers.interval import IntervalTrigger
from django.utils.timezone import now
from core.scheduler import scheduler
logger = logging.getLogger(__name__)
def check_watcher(watcher_pk: int) -> None:
from pricemon.models import PriceWatcher, PriceSnapshot, PriceAlert
from pricemon.scraper import fetch_price
from pricemon.notifications import notify_telegram
try:
watcher = PriceWatcher.objects.get(pk=watcher_pk)
except PriceWatcher.DoesNotExist:
logger.warning(f'PriceWatcher {watcher_pk} no longer exists, skipping')
return
price, raw_text, error = fetch_price(watcher)
PriceSnapshot.objects.create(
watcher=watcher,
price=price,
raw_text=raw_text or '',
error=error or '',
)
if price is not None and watcher.last_price is not None and price < watcher.last_price:
drop_pct = (watcher.last_price - price) / watcher.last_price * 100
if drop_pct >= watcher.alert_threshold_pct:
alert = PriceAlert.objects.create(
watcher=watcher,
old_price=watcher.last_price,
new_price=price,
drop_pct=drop_pct,
)
notify_telegram(watcher, alert)
update_fields = ['last_checked_at']
if price is not None:
watcher.last_price = price
update_fields.append('last_price')
watcher.last_checked_at = now()
watcher.save(update_fields=update_fields)
logger.info(f'Checked watcher {watcher_pk} ({watcher.name}): price={price}')
def schedule_watcher(watcher) -> None:
job_id = f'pricemon_watcher_{watcher.pk}'
scheduler.add_job(
check_watcher,
trigger=IntervalTrigger(hours=watcher.check_interval_hours),
id=job_id,
args=[watcher.pk],
replace_existing=True,
)
logger.info(f'Scheduled pricemon job {job_id} every {watcher.check_interval_hours}h')
def unschedule_watcher(watcher) -> None:
job_id = f'pricemon_watcher_{watcher.pk}'
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
logger.info(f'Removed pricemon job {job_id}')
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path
from pricemon import views
urlpatterns = [
path('', views.DashboardView.as_view(), name='pricemon-dashboard'),
path('add/', views.WatcherCreateView.as_view(), name='pricemon-add'),
path('<int:pk>/edit/', views.WatcherEditView.as_view(), name='pricemon-edit'),
path('<int:pk>/delete/', views.WatcherDeleteView.as_view(), name='pricemon-delete'),
path('<int:pk>/history/', views.WatcherHistoryView.as_view(), name='pricemon-history'),
path('<int:pk>/check/', views.CheckNowView.as_view(), name='pricemon-check-now'),
path('api/alerts/', views.AlertsPartialView.as_view(), name='pricemon-alerts-partial'),
path('api/alerts/<int:pk>/dismiss/', views.DismissAlertView.as_view(), name='pricemon-dismiss-alert'),
path('api/chart/<int:pk>/', views.ChartDataView.as_view(), name='pricemon-chart-data'),
path('api/test-selector/', views.TestSelectorView.as_view(), name='pricemon-test-selector'),
]
+161
View File
@@ -0,0 +1,161 @@
import logging
from django.contrib import messages
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views import View
from django.views.generic import TemplateView
from pricemon.forms import PriceWatcherForm
from pricemon.models import PriceAlert, PriceSnapshot, PriceWatcher
logger = logging.getLogger(__name__)
class DashboardView(TemplateView):
template_name = 'pricemon/dashboard.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
watchers = PriceWatcher.objects.all()
active_alerts = PriceAlert.objects.filter(dismissed=False).select_related('watcher')
ctx['watchers'] = watchers
ctx['total_watchers'] = watchers.count()
ctx['active_alerts_count'] = active_alerts.count()
ctx['active_alerts'] = active_alerts[:10]
return ctx
class WatcherCreateView(View):
template_name = 'pricemon/watcher_form.html'
def get(self, request):
return render(request, self.template_name, {'form': PriceWatcherForm(), 'title': 'Add Watcher'})
def post(self, request):
form = PriceWatcherForm(request.POST)
if form.is_valid():
watcher = form.save()
messages.success(request, f'Watcher "{watcher.name}" created.')
return redirect('pricemon-dashboard')
return render(request, self.template_name, {'form': form, 'title': 'Add Watcher'})
class WatcherEditView(View):
template_name = 'pricemon/watcher_form.html'
def get(self, request, pk):
watcher = get_object_or_404(PriceWatcher, pk=pk)
return render(request, self.template_name, {
'form': PriceWatcherForm(instance=watcher),
'watcher': watcher,
'title': f'Edit — {watcher.name}',
})
def post(self, request, pk):
watcher = get_object_or_404(PriceWatcher, pk=pk)
form = PriceWatcherForm(request.POST, instance=watcher)
if form.is_valid():
form.save()
messages.success(request, f'Watcher "{watcher.name}" updated.')
return redirect('pricemon-dashboard')
return render(request, self.template_name, {
'form': form,
'watcher': watcher,
'title': f'Edit — {watcher.name}',
})
class WatcherDeleteView(View):
def post(self, request, pk):
watcher = get_object_or_404(PriceWatcher, pk=pk)
name = watcher.name
watcher.delete()
messages.success(request, f'Watcher "{name}" deleted.')
return redirect('pricemon-dashboard')
class WatcherHistoryView(TemplateView):
template_name = 'pricemon/history.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
watcher = get_object_or_404(PriceWatcher, pk=kwargs['pk'])
ctx['watcher'] = watcher
ctx['snapshots'] = watcher.snapshots.all()[:200]
return ctx
class CheckNowView(View):
def post(self, request, pk):
from pricemon.tasks import check_watcher
watcher = get_object_or_404(PriceWatcher, pk=pk)
try:
check_watcher(watcher.pk)
watcher.refresh_from_db()
msg = f'Check complete. Current price: ${watcher.last_price}' if watcher.last_price else 'Check complete (price not detected).'
if request.headers.get('HX-Request'):
return JsonResponse({'ok': True, 'message': msg})
messages.success(request, msg)
except Exception as exc:
logger.error(f'Manual check failed for watcher {pk}: {exc}')
if request.headers.get('HX-Request'):
return JsonResponse({'ok': False, 'message': str(exc)}, status=500)
messages.error(request, f'Check failed: {exc}')
return redirect('pricemon-dashboard')
class AlertsPartialView(View):
def get(self, request):
alerts = PriceAlert.objects.filter(dismissed=False).select_related('watcher')[:20]
return render(request, 'pricemon/_alerts.html', {'alerts': alerts})
class DismissAlertView(View):
def post(self, request, pk):
alert = get_object_or_404(PriceAlert, pk=pk)
alert.dismissed = True
alert.save(update_fields=['dismissed'])
if request.headers.get('HX-Request'):
return HttpResponse('')
return redirect('pricemon-dashboard')
class ChartDataView(View):
def get(self, request, pk):
watcher = get_object_or_404(PriceWatcher, pk=pk)
snapshots = (
watcher.snapshots
.filter(price__isnull=False)
.values('checked_at', 'price')
.order_by('checked_at')[:500]
)
labels = [s['checked_at'].strftime('%Y-%m-%d %H:%M') for s in snapshots]
prices = [float(s['price']) for s in snapshots]
return JsonResponse({'labels': labels, 'prices': prices, 'name': watcher.name})
class TestSelectorView(View):
def post(self, request):
from pricemon.scraper import _fetch_with_requests, _fetch_with_playwright, parse_price
url = request.POST.get('url', '').strip()
css_selector = request.POST.get('css_selector', '').strip()
if not url or not css_selector:
return render(request, 'pricemon/_selector_test_result.html', {
'error': 'Both URL and CSS selector are required.',
})
raw_text, error = _fetch_with_requests(url, css_selector)
method = 'requests'
if not raw_text and not error:
raw_text, error = _fetch_with_playwright(url, css_selector)
method = 'playwright'
price = parse_price(raw_text) if raw_text else None
return render(request, 'pricemon/_selector_test_result.html', {
'raw_text': raw_text,
'price': price,
'error': error,
'method': method,
})
+17
View File
@@ -151,6 +151,16 @@
</div>
</a>
<a href="{% url 'pricemon-dashboard' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
{% trans "Price Monitor" %}
</div>
</a>
<a href="{% url 'routermon-dashboard' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -275,6 +285,13 @@
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.9/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script>
// Pass Django CSRF token to all HTMX requests automatically
document.addEventListener('htmx:configRequest', function(e) {
var token = document.cookie.match(/csrftoken=([^;]+)/)?.[1];
if (token) e.detail.headers['X-CSRFToken'] = token;
});
</script>
<script src="{% static 'js/navbar.js' %}"></script>
<script>
$(document).ready(function() {
+36
View File
@@ -0,0 +1,36 @@
{% if alerts %}
<div class="mb-6">
<h2 class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-red-500 inline-block"></span>
{% trans "Active Price Drop Alerts" %}
</h2>
<div class="space-y-2">
{% for alert in alerts %}
<div class="flex items-center justify-between bg-red-50 border border-red-100 rounded-lg px-4 py-3"
id="alert-{{ alert.pk }}">
<div class="flex items-center gap-3">
<span class="text-red-500 text-lg">💰</span>
<div>
<span class="font-medium text-gray-900">{{ alert.watcher.name }}</span>
<span class="text-sm text-gray-500 ml-2">
${{ alert.old_price }} → <strong class="text-red-600">${{ alert.new_price }}</strong>
<span class="text-green-700 font-medium">({{ alert.drop_pct|floatformat:1 }}% off)</span>
</span>
</div>
</div>
<div class="flex items-center gap-2">
<a href="{{ alert.watcher.url }}" target="_blank" rel="noopener"
class="text-xs text-indigo-500 hover:underline">{% trans "View" %}</a>
<button
hx-post="{% url 'pricemon-dismiss-alert' alert.pk %}"
hx-target="#alert-{{ alert.pk }}"
hx-swap="outerHTML"
class="text-xs px-2 py-1 bg-white border border-gray-200 rounded text-gray-600 hover:bg-gray-100 transition">
{% trans "Dismiss" %}
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
@@ -0,0 +1,25 @@
{% if error %}
<div class="flex items-start gap-2 rounded-md bg-red-50 border border-red-100 px-3 py-2.5 text-sm text-red-700">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span>{{ error }}</span>
</div>
{% elif price %}
<div class="flex items-start gap-2 rounded-md bg-green-50 border border-green-100 px-3 py-2.5 text-sm text-green-700">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span>
Price detected: <strong>${{ price }}</strong>
<span class="text-green-500 ml-1">(raw: "{{ raw_text }}", via {{ method }})</span>
</span>
</div>
{% else %}
<div class="flex items-start gap-2 rounded-md bg-yellow-50 border border-yellow-100 px-3 py-2.5 text-sm text-yellow-700">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Element found ("{{ raw_text }}") but no numeric price could be parsed from it.</span>
</div>
{% endif %}
+179
View File
@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Price Monitor" %}{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">{% trans "Price Monitor" %}</h1>
<p class="text-sm text-gray-500 mt-0.5">{% trans "Track product prices and get notified on drops" %}</p>
</div>
<a href="{% url 'pricemon-add' %}"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{% trans "Add Watcher" %}
</a>
<a href="{% url 'site-settings' %}"
class="inline-flex items-center px-3 py-2 bg-white border border-gray-200 text-gray-600 text-sm font-medium rounded-lg hover:bg-gray-50 transition" title="Settings (Telegram, etc.)">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</a>
</div>
<!-- Stat cards -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">{% trans "Watchers" %}</div>
<div class="text-2xl font-bold text-gray-900">{{ total_watchers }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">{% trans "Active Alerts" %}</div>
<div class="text-2xl font-bold {% if active_alerts_count %}text-red-600{% else %}text-gray-900{% endif %}">
{{ active_alerts_count }}
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
<div class="text-xs text-gray-400 font-medium uppercase tracking-wide mb-1">{% trans "Enabled" %}</div>
<div class="text-2xl font-bold text-gray-900">
{% with watchers|length as total %}
{% for w in watchers %}{% if w.enabled %}{% endif %}{% endfor %}
{{ watchers|length }}
{% endwith %}
</div>
</div>
</div>
<!-- Active Alerts Panel -->
{% if active_alerts %}
<div id="alerts-container"
hx-get="{% url 'pricemon-alerts-partial' %}"
hx-trigger="every 30s"
hx-swap="innerHTML">
{% include 'pricemon/_alerts.html' %}
</div>
{% endif %}
<!-- Watchers Table -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
{% if watchers %}
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Product" %}</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Current Price" %}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">{% trans "Last Checked" %}</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden md:table-cell">{% trans "Interval" %}</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Status" %}</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for watcher in watchers %}
<tr class="hover:bg-gray-50" id="watcher-row-{{ watcher.pk }}">
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{{ watcher.name }}</div>
<a href="{{ watcher.url }}" target="_blank" rel="noopener"
class="text-xs text-indigo-500 hover:underline truncate block max-w-xs">
{{ watcher.url|truncatechars:60 }}
</a>
</td>
<td class="px-4 py-3 text-right">
{% if watcher.last_price %}
<span class="text-lg font-bold text-gray-900">${{ watcher.last_price }}</span>
{% else %}
<span class="text-gray-400"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-500 hidden sm:table-cell">
{% if watcher.last_checked_at %}
{{ watcher.last_checked_at|timesince }} {% trans "ago" %}
{% else %}
<span class="text-gray-300">{% trans "Never" %}</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-500 hidden md:table-cell">
{{ watcher.get_check_interval_hours_display }}
</td>
<td class="px-4 py-3 text-center">
{% if watcher.enabled %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">{% trans "Active" %}</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">{% trans "Paused" %}</span>
{% endif %}
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<!-- Check Now -->
<button
hx-post="{% url 'pricemon-check-now' watcher.pk %}"
hx-confirm="Run a price check now for {{ watcher.name }}?"
hx-swap="none"
hx-on::after-request="if(event.detail.successful) window.location.reload()"
title="Check Now"
class="p-1.5 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
<!-- History -->
<a href="{% url 'pricemon-history' watcher.pk %}"
title="Price History"
class="p-1.5 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</a>
<!-- Edit -->
<a href="{% url 'pricemon-edit' watcher.pk %}"
title="Edit"
class="p-1.5 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<!-- Delete -->
<form method="post" action="{% url 'pricemon-delete' watcher.pk %}" class="inline"
onsubmit="return confirm('Delete watcher {{ watcher.name }}?')">
{% csrf_token %}
<button type="submit" title="Delete"
class="p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-16 text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
<p class="font-medium">{% trans "No watchers yet" %}</p>
<p class="text-sm mt-1">
<a href="{% url 'pricemon-add' %}" class="text-indigo-500 hover:underline">{% trans "Add your first price watcher" %}</a>
</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+104
View File
@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{{ watcher.name }} — {% trans "Price History" %}{% endblock %}
{% block content %}
<div class="max-w-5xl mx-auto px-4 py-6">
<div class="flex items-center gap-3 mb-2">
<a href="{% url 'pricemon-dashboard' %}"
class="text-gray-400 hover:text-gray-600 transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<h1 class="text-xl font-bold text-gray-900">{{ watcher.name }}</h1>
</div>
<a href="{{ watcher.url }}" target="_blank" rel="noopener"
class="text-sm text-indigo-500 hover:underline ml-8 mb-6 block">{{ watcher.url|truncatechars:80 }}</a>
<!-- Price Chart -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-4 mb-6">
<div class="text-sm font-semibold text-gray-700 mb-3">{% trans "Price History" %}</div>
<div id="priceChart" style="height: 300px;"></div>
</div>
<!-- Snapshot Table -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-700">
{% trans "Recent Checks" %}
</div>
{% if snapshots %}
<table class="min-w-full divide-y divide-gray-100 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Checked At" %}</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Price" %}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Raw Text" %}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{% trans "Error" %}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% for snap in snapshots %}
<tr>
<td class="px-4 py-2 text-gray-500">{{ snap.checked_at|date:"Y-m-d H:i" }}</td>
<td class="px-4 py-2 text-right font-medium {% if snap.price %}text-gray-900{% else %}text-gray-300{% endif %}">
{% if snap.price %}${{ snap.price }}{% else %}—{% endif %}
</td>
<td class="px-4 py-2 text-gray-500 font-mono text-xs">{{ snap.raw_text|truncatechars:40 }}</td>
<td class="px-4 py-2 text-red-500 text-xs">{{ snap.error|truncatechars:60 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="py-10 text-center text-gray-400 text-sm">{% trans "No price checks yet." %}</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.min.js"></script>
<script>
(function () {
const chart = echarts.init(document.getElementById('priceChart'));
chart.showLoading();
fetch('{% url "pricemon-chart-data" watcher.pk %}')
.then(r => r.json())
.then(data => {
chart.hideLoading();
if (!data.labels.length) {
chart.setOption({ title: { text: 'No data yet', left: 'center', top: 'center', textStyle: { color: '#9ca3af', fontSize: 14 } } });
return;
}
chart.setOption({
tooltip: { trigger: 'axis', formatter: p => `${p[0].axisValue}<br/>$${p[0].value}` },
grid: { left: 60, right: 20, top: 20, bottom: 40 },
xAxis: { type: 'category', data: data.labels, axisLabel: { rotate: 30, fontSize: 11 } },
yAxis: {
type: 'value',
axisLabel: { formatter: v => '$' + v },
scale: true,
},
series: [{
name: data.name,
type: 'line',
data: data.prices,
smooth: true,
symbol: 'circle',
symbolSize: 5,
lineStyle: { color: '#6366f1', width: 2 },
itemStyle: { color: '#6366f1' },
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(99,102,241,0.18)' }, { offset: 1, color: 'rgba(99,102,241,0)' }] } },
}],
});
});
window.addEventListener('resize', () => chart.resize());
})();
</script>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Price Monitor — Settings" %}{% endblock %}
{% block content %}
<div class="max-w-xl mx-auto px-4 py-6">
<div class="flex items-center gap-3 mb-6">
<a href="{% url 'pricemon-dashboard' %}"
class="text-gray-400 hover:text-gray-600 transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<h1 class="text-xl font-bold text-gray-900">{% trans "Price Monitor Settings" %}</h1>
</div>
{% if messages %}
{% for msg in messages %}
<div class="mb-4 px-4 py-3 rounded-lg text-sm
{% if msg.tags == 'error' %}bg-red-50 text-red-700 border border-red-100
{% else %}bg-green-50 text-green-700 border border-green-100{% endif %}">
{{ msg }}
</div>
{% endfor %}
{% endif %}
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
<h2 class="text-sm font-semibold text-gray-700 mb-4">{% trans "Telegram Notifications" %}</h2>
<form method="post" class="space-y-5">
{% csrf_token %}
{% for field in form %}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<p class="text-xs text-gray-400 mt-1">{{ field.help_text }}</p>
{% endif %}
{% for error in field.errors %}
<p class="text-xs text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
{% endfor %}
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
{% trans "Save" %}
</button>
<a href="?test_telegram=1"
class="px-4 py-2 bg-blue-50 text-blue-700 text-sm font-medium rounded-lg hover:bg-blue-100 transition">
{% trans "Send Test Message" %}
</a>
</div>
</form>
</div>
<p class="mt-4 text-xs text-gray-400">
{% trans "These credentials apply to all price drop alerts. Create a bot via" %}
<a href="https://t.me/BotFather" target="_blank" class="text-indigo-400 hover:underline">@BotFather</a>
{% trans "and get your chat ID from" %}
<a href="https://t.me/userinfobot" target="_blank" class="text-indigo-400 hover:underline">@userinfobot</a>.
</p>
</div>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto px-4 py-6">
<div class="flex items-center gap-3 mb-6">
<a href="{% url 'pricemon-dashboard' %}"
class="text-gray-400 hover:text-gray-600 transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<h1 class="text-xl font-bold text-gray-900">{{ title }}</h1>
</div>
{% if messages %}
{% for msg in messages %}
<div class="mb-4 px-4 py-3 rounded-lg text-sm
{% if msg.tags == 'error' %}bg-red-50 text-red-700 border border-red-100
{% else %}bg-green-50 text-green-700 border border-green-100{% endif %}">
{{ msg }}
</div>
{% endfor %}
{% endif %}
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
<form method="post" id="watcher-form" class="space-y-5">
{% csrf_token %}
{% for field in form %}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ field.label }}{% if field.field.required %} <span class="text-red-500">*</span>{% endif %}
</label>
{% if field.html_name == 'css_selector' %}
{# CSS selector field gets an inline Test button #}
<div class="flex gap-2 items-start">
<div class="flex-1">{{ field }}</div>
<button type="button"
id="test-selector-btn"
class="flex-shrink-0 mt-px inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 transition"
hx-post="{% url 'pricemon-test-selector' %}"
hx-include="#watcher-form [name='url'], #watcher-form [name='css_selector']"
hx-target="#selector-test-result"
hx-swap="innerHTML"
hx-indicator="#test-selector-btn">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="htmx-indicator-hide">{% trans "Test" %}</span>
<span class="htmx-indicator-show hidden">{% trans "Testing…" %}</span>
</button>
</div>
<div id="selector-test-result" class="mt-2"></div>
{% else %}
{{ field }}
{% endif %}
{% if field.help_text %}
<p class="text-xs text-gray-400 mt-1">{{ field.help_text }}</p>
{% endif %}
{% for error in field.errors %}
<p class="text-xs text-red-600 mt-1">{{ error }}</p>
{% endfor %}
</div>
{% endfor %}
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
{% trans "Save" %}
</button>
<a href="{% url 'pricemon-dashboard' %}"
class="px-5 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition">
{% trans "Cancel" %}
</a>
</div>
</form>
</div>
<div class="mt-4 text-xs text-gray-400">
<p>{% trans "Selector tip:" %} inspect the price on the product page and find its CSS class. E.g. <code class="bg-gray-100 px-1 rounded">.product-price</code> or <code class="bg-gray-100 px-1 rounded">span[itemprop="price"]</code></p>
</div>
</div>
{% block extra_js %}
<script>
/* Show/hide the loading text on the Test button while HTMX is in-flight */
document.addEventListener('htmx:beforeRequest', function(e) {
if (e.target.id === 'test-selector-btn') {
e.target.querySelector('.htmx-indicator-hide').classList.add('hidden');
e.target.querySelector('.htmx-indicator-show').classList.remove('hidden');
e.target.disabled = true;
}
});
document.addEventListener('htmx:afterRequest', function(e) {
if (e.target.id === 'test-selector-btn') {
e.target.querySelector('.htmx-indicator-hide').classList.remove('hidden');
e.target.querySelector('.htmx-indicator-show').classList.add('hidden');
e.target.disabled = false;
}
});
</script>
{% endblock %}
{% endblock %}