mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add price check
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
|
||||
'netscan',
|
||||
'nginxmon',
|
||||
'routermon',
|
||||
'pricemon',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
@@ -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.'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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']
|
||||
@@ -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}')
|
||||
@@ -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.',
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}')
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user