AI: Portfolio and Account snapshot pages
This commit is contained in:
parent
618072069e
commit
69067e1fd8
|
|
@ -1,10 +1,22 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import Account, AccountHolding, Asset, BrokerageConnection, IngestionRun
|
||||
from .models import (
|
||||
Account,
|
||||
AccountHolding,
|
||||
AccountSnapshot,
|
||||
Asset,
|
||||
AssetPriceSnapshot,
|
||||
BrokerageConnection,
|
||||
HoldingSnapshot,
|
||||
IngestionRun,
|
||||
)
|
||||
|
||||
# Register your models here.
|
||||
admin.site.register(Account)
|
||||
admin.site.register(AccountHolding)
|
||||
admin.site.register(AccountSnapshot)
|
||||
admin.site.register(Asset)
|
||||
admin.site.register(AssetPriceSnapshot)
|
||||
admin.site.register(BrokerageConnection)
|
||||
admin.site.register(HoldingSnapshot)
|
||||
admin.site.register(IngestionRun)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
# Generated by Django 6.0.5 on 2026-05-24 01:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('overview', '0005_remove_brokerageconnection_access_token_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccountSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('balance', models.IntegerField()),
|
||||
('snapshot_at', models.DateTimeField()),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.account')),
|
||||
('ingestion_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.ingestionrun')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('ingestion_run', 'account'), name='unique_account_snapshot_per_run')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AssetPriceSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.FloatField()),
|
||||
('snapshot_at', models.DateTimeField()),
|
||||
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.asset')),
|
||||
('ingestion_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.ingestionrun')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('ingestion_run', 'asset'), name='unique_asset_snapshot_per_run')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HoldingSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.FloatField()),
|
||||
('cost_basis', models.FloatField()),
|
||||
('market_price', models.FloatField()),
|
||||
('value', models.FloatField()),
|
||||
('snapshot_at', models.DateTimeField()),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.account')),
|
||||
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.asset')),
|
||||
('holding', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.accountholding')),
|
||||
('ingestion_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='overview.ingestionrun')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('ingestion_run', 'holding'), name='unique_holding_snapshot_per_run')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -124,3 +124,53 @@ class IngestionRun(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"{self.connection} [{self.status}]"
|
||||
|
||||
|
||||
class AccountSnapshot(models.Model):
|
||||
ingestion_run = models.ForeignKey(IngestionRun, on_delete=models.CASCADE)
|
||||
account = models.ForeignKey(Account, on_delete=models.CASCADE)
|
||||
balance = models.IntegerField()
|
||||
snapshot_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["ingestion_run", "account"],
|
||||
name="unique_account_snapshot_per_run",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class AssetPriceSnapshot(models.Model):
|
||||
ingestion_run = models.ForeignKey(IngestionRun, on_delete=models.CASCADE)
|
||||
asset = models.ForeignKey(Asset, on_delete=models.CASCADE)
|
||||
price = models.FloatField()
|
||||
snapshot_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["ingestion_run", "asset"],
|
||||
name="unique_asset_snapshot_per_run",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class HoldingSnapshot(models.Model):
|
||||
ingestion_run = models.ForeignKey(IngestionRun, on_delete=models.CASCADE)
|
||||
holding = models.ForeignKey(AccountHolding, on_delete=models.CASCADE)
|
||||
account = models.ForeignKey(Account, on_delete=models.CASCADE)
|
||||
asset = models.ForeignKey(Asset, on_delete=models.CASCADE)
|
||||
amount = models.FloatField()
|
||||
cost_basis = models.FloatField()
|
||||
market_price = models.FloatField()
|
||||
value = models.FloatField()
|
||||
snapshot_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["ingestion_run", "holding"],
|
||||
name="unique_holding_snapshot_per_run",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,16 @@ from django.utils import timezone
|
|||
from .brokerage_types import BrokerageSnapshot
|
||||
from .brokerages.base import BaseBrokerageAdapter
|
||||
from .brokerages.registry import get_brokerage_adapter
|
||||
from .models import Account, AccountHolding, Asset, BrokerageConnection, IngestionRun
|
||||
from .models import (
|
||||
Account,
|
||||
AccountHolding,
|
||||
AccountSnapshot,
|
||||
Asset,
|
||||
AssetPriceSnapshot,
|
||||
BrokerageConnection,
|
||||
HoldingSnapshot,
|
||||
IngestionRun,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -34,7 +43,7 @@ class BrokerageIngestionService:
|
|||
|
||||
try:
|
||||
snapshot = self.adapter.fetch_snapshot()
|
||||
result = self._persist_snapshot(snapshot)
|
||||
result = self._persist_snapshot(snapshot, run)
|
||||
except Exception as exc:
|
||||
run.status = IngestionRun.STATUS_FAILED
|
||||
run.error_message = str(exc)
|
||||
|
|
@ -61,9 +70,14 @@ class BrokerageIngestionService:
|
|||
return run
|
||||
|
||||
@transaction.atomic
|
||||
def _persist_snapshot(self, snapshot: BrokerageSnapshot) -> IngestionResult:
|
||||
def _persist_snapshot(
|
||||
self,
|
||||
snapshot: BrokerageSnapshot,
|
||||
run: IngestionRun,
|
||||
) -> IngestionResult:
|
||||
accounts_by_external_id = {}
|
||||
assets_by_external_id = {}
|
||||
snapshot_at = run.started_at or timezone.now()
|
||||
|
||||
for payload in snapshot.accounts:
|
||||
account, _ = Account.objects.update_or_create(
|
||||
|
|
@ -76,6 +90,14 @@ class BrokerageIngestionService:
|
|||
},
|
||||
)
|
||||
accounts_by_external_id[payload.external_id] = account
|
||||
AccountSnapshot.objects.update_or_create(
|
||||
ingestion_run=run,
|
||||
account=account,
|
||||
defaults={
|
||||
"balance": payload.balance,
|
||||
"snapshot_at": snapshot_at,
|
||||
},
|
||||
)
|
||||
|
||||
for payload in snapshot.assets:
|
||||
asset, _ = Asset.objects.update_or_create(
|
||||
|
|
@ -87,11 +109,19 @@ class BrokerageIngestionService:
|
|||
},
|
||||
)
|
||||
assets_by_external_id[payload.external_id] = asset
|
||||
AssetPriceSnapshot.objects.update_or_create(
|
||||
ingestion_run=run,
|
||||
asset=asset,
|
||||
defaults={
|
||||
"price": payload.price,
|
||||
"snapshot_at": snapshot_at,
|
||||
},
|
||||
)
|
||||
|
||||
for payload in snapshot.holdings:
|
||||
account = accounts_by_external_id[payload.account_external_id]
|
||||
asset = assets_by_external_id[payload.asset_external_id]
|
||||
AccountHolding.objects.update_or_create(
|
||||
holding, _ = AccountHolding.objects.update_or_create(
|
||||
provider=self.connection.provider,
|
||||
external_id=payload.external_id,
|
||||
defaults={
|
||||
|
|
@ -103,6 +133,19 @@ class BrokerageIngestionService:
|
|||
"purchase_date": payload.purchase_date,
|
||||
},
|
||||
)
|
||||
HoldingSnapshot.objects.update_or_create(
|
||||
ingestion_run=run,
|
||||
holding=holding,
|
||||
defaults={
|
||||
"account": account,
|
||||
"asset": asset,
|
||||
"amount": payload.amount,
|
||||
"cost_basis": payload.cost_basis,
|
||||
"market_price": asset.price,
|
||||
"value": payload.amount * asset.price,
|
||||
"snapshot_at": snapshot_at,
|
||||
},
|
||||
)
|
||||
|
||||
return IngestionResult(
|
||||
accounts_processed=len(snapshot.accounts),
|
||||
|
|
|
|||
|
|
@ -3,25 +3,117 @@
|
|||
{% block title %}Overview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>This is cool!!!</h1>
|
||||
|
||||
|
||||
<!-- Accounts list -->
|
||||
<div class="list-group">
|
||||
{% for account in accounts %}
|
||||
<a href="portfolio/{{ account.id }}" class="list-group-item list-group-item-action">
|
||||
<div class="row align-items-start">
|
||||
<div class="col">
|
||||
<h2>{{ account.name }}</h2>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p>${{ account.balance }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
<div class="section-panel">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3">
|
||||
<div>
|
||||
<h1 class="h2 mb-2">Portfolio Overview</h1>
|
||||
<p class="text-secondary mb-0">Latest synced account balances and historical portfolio value.</p>
|
||||
</div>
|
||||
<div class="text-lg-end">
|
||||
<div class="metric-label">Accounts</div>
|
||||
<div class="metric-value">{{ accounts|length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-lg-8">
|
||||
<section class="section-panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Portfolio Value Over Time</h2>
|
||||
<p class="text-secondary mb-0">Running total based on the latest known balance for each account at every sync.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-end gap-3 flex-wrap mb-3">
|
||||
{% if latest_portfolio_point %}
|
||||
<div class="text-end">
|
||||
<div class="metric-label">Latest Total</div>
|
||||
<div class="fw-semibold fs-5">${{ latest_portfolio_point.display_value }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="get" class="range-form">
|
||||
<label for="portfolio-range" class="metric-label mb-0">Range</label>
|
||||
<select id="portfolio-range" name="range" class="form-select form-select-sm range-select" onchange="this.form.submit()">
|
||||
{% for value, label in date_range_options %}
|
||||
<option value="{{ value }}"{% if selected_range == value %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if portfolio_timeline %}
|
||||
<div class="chart-frame">
|
||||
<div class="chart-axis chart-axis-top">
|
||||
<span>${{ portfolio_chart.max_label }}</span>
|
||||
<span>Change shown on hover</span>
|
||||
</div>
|
||||
<svg class="chart-svg" viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label="Portfolio value over time">
|
||||
<line class="chart-baseline" x1="0" y1="100" x2="100" y2="100"></line>
|
||||
<path class="chart-line" d="{{ portfolio_chart.path }}"></path>
|
||||
{% for point in portfolio_chart.points %}
|
||||
<circle class="chart-point" cx="{{ point.x }}" cy="{{ point.y }}" r="2.2">
|
||||
<title>{{ point.label }} | ${{ point.display_value }} | {{ point.change_display }}</title>
|
||||
</circle>
|
||||
{% endfor %}
|
||||
</svg>
|
||||
<div class="chart-axis chart-axis-bottom">
|
||||
<span>{{ portfolio_chart.start_label }}</span>
|
||||
<span>${{ portfolio_chart.min_label }}</span>
|
||||
<span>{{ portfolio_chart.end_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-secondary mb-0">Run a brokerage sync to start building a portfolio history.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<section class="section-panel h-100">
|
||||
<h2 class="h5 mb-3">Recent Sync Points</h2>
|
||||
{% if portfolio_timeline %}
|
||||
<div class="list-group list-group-flush timeline-list">
|
||||
{% for point in portfolio_timeline %}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between gap-3">
|
||||
<span class="text-secondary">{{ point.label }}</span>
|
||||
<strong>${{ point.display_value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-secondary mb-0">No historical snapshots yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="section-panel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5 mb-0">Accounts</h2>
|
||||
<span class="text-secondary small">Click an account to view holdings and its value history.</span>
|
||||
</div>
|
||||
|
||||
<div class="list-group">
|
||||
{% for account in accounts %}
|
||||
<a href="portfolio/{{ account.id }}" class="list-group-item list-group-item-action">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-12 col-md-8">
|
||||
<h3 class="h6 mb-1">{{ account.name }}</h3>
|
||||
<div class="text-secondary small">{{ account.provider|default:"manual" }}{% if account.number %} • {{ account.number }}{% endif %}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 text-md-end mt-2 mt-md-0">
|
||||
<strong>${{ account.balance }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item">
|
||||
<p class="text-secondary mb-0">No accounts yet. Run a brokerage sync to populate this view.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -3,46 +3,131 @@
|
|||
{% block title %}{{ account.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>This is cool!!!</h1>
|
||||
|
||||
<!-- Holdings list -->
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<div class="row align-items-start">
|
||||
<div class="col">
|
||||
<h3>Ticker</h2>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3>Value</h2>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3>Amount</h2>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3>Cost Basis</h2>
|
||||
</div>
|
||||
<div class="section-panel">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3">
|
||||
<div>
|
||||
<h1 class="h2 mb-2">{{ account.name }}</h1>
|
||||
<p class="text-secondary mb-0">{{ account.provider|default:"manual" }}{% if account.number %} • {{ account.number }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="text-lg-end">
|
||||
<div class="metric-label">Current Balance</div>
|
||||
<div class="metric-value">${{ account.balance }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% for holding in holdings %}
|
||||
<a href="asset/{{ holding.asset.ticker }}" class="list-group-item list-group-item-action">
|
||||
<div class="row align-items-start">
|
||||
<div class="col">
|
||||
<h2>{{ holding.asset.ticker }}</h2>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p>${{ holding.holding_value }} ( ${{ holding.holding_gain }} )</p>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p>{{ holding.ammount }}</p>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p>${{ holding.cost_basis }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-lg-8">
|
||||
<section class="section-panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Account Value Over Time</h2>
|
||||
<p class="text-secondary mb-0">Historical balance snapshots recorded each time this account is synced.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-end gap-3 flex-wrap mb-3">
|
||||
{% if latest_account_point %}
|
||||
<div class="text-end">
|
||||
<div class="metric-label">Latest Snapshot</div>
|
||||
<div class="fw-semibold fs-5">${{ latest_account_point.display_value }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="get" class="range-form">
|
||||
<label for="account-range" class="metric-label mb-0">Range</label>
|
||||
<select id="account-range" name="range" class="form-select form-select-sm range-select" onchange="this.form.submit()">
|
||||
{% for value, label in date_range_options %}
|
||||
<option value="{{ value }}"{% if selected_range == value %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if account_timeline %}
|
||||
<div class="chart-frame">
|
||||
<div class="chart-axis chart-axis-top">
|
||||
<span>${{ account_chart.max_label }}</span>
|
||||
<span>Change shown on hover</span>
|
||||
</div>
|
||||
<svg class="chart-svg" viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label="Account value over time">
|
||||
<line class="chart-baseline" x1="0" y1="100" x2="100" y2="100"></line>
|
||||
<path class="chart-line" d="{{ account_chart.path }}"></path>
|
||||
{% for point in account_chart.points %}
|
||||
<circle class="chart-point" cx="{{ point.x }}" cy="{{ point.y }}" r="2.2">
|
||||
<title>{{ point.label }} | ${{ point.display_value }} | {{ point.change_display }}</title>
|
||||
</circle>
|
||||
{% endfor %}
|
||||
</svg>
|
||||
<div class="chart-axis chart-axis-bottom">
|
||||
<span>{{ account_chart.start_label }}</span>
|
||||
<span>${{ account_chart.min_label }}</span>
|
||||
<span>{{ account_chart.end_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-secondary mb-0">No account history yet. Run another sync to capture snapshots.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<section class="section-panel h-100">
|
||||
<h2 class="h5 mb-3">Snapshot Timeline</h2>
|
||||
{% if account_timeline %}
|
||||
<div class="list-group list-group-flush timeline-list">
|
||||
{% for point in account_timeline %}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between gap-3">
|
||||
<span class="text-secondary">{{ point.label }}</span>
|
||||
<strong>${{ point.display_value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-secondary mb-0">No snapshot points available yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="section-panel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5 mb-0">Current Holdings</h2>
|
||||
<span class="text-secondary small">{{ holdings|length }} position{{ holdings|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-3"><strong>Ticker</strong></div>
|
||||
<div class="col-3"><strong>Value</strong></div>
|
||||
<div class="col-3"><strong>Amount</strong></div>
|
||||
<div class="col-3"><strong>Cost Basis</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
{% for holding in holdings %}
|
||||
<div class="list-group-item">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-3">
|
||||
<strong>{{ holding.asset.ticker }}</strong>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
${{ holding.holding_value|floatformat:2 }}
|
||||
<div class="small text-secondary">${{ holding.holding_gain|floatformat:2 }}</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
{{ holding.ammount|floatformat:4 }}
|
||||
</div>
|
||||
<div class="col-3">
|
||||
${{ holding.cost_basis|floatformat:2 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="list-group-item">
|
||||
<p class="text-secondary mb-0">No holdings available for this account.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone as django_timezone
|
||||
|
||||
from .brokerage_types import (
|
||||
BrokerageAccountPayload,
|
||||
|
|
@ -14,7 +15,16 @@ from .brokerage_types import (
|
|||
from .brokerages.base import BaseBrokerageAdapter, BrokerageAdapterError
|
||||
from .brokerages.random import RandomBrokerageAdapter
|
||||
from .brokerages.registry import register_brokerage_adapter
|
||||
from .models import Account, AccountHolding, Asset, BrokerageConnection, IngestionRun
|
||||
from .models import (
|
||||
Account,
|
||||
AccountHolding,
|
||||
AccountSnapshot,
|
||||
Asset,
|
||||
AssetPriceSnapshot,
|
||||
BrokerageConnection,
|
||||
HoldingSnapshot,
|
||||
IngestionRun,
|
||||
)
|
||||
from .services import BrokerageIngestionService
|
||||
|
||||
|
||||
|
|
@ -136,14 +146,21 @@ class BrokerageIngestionServiceTests(TestCase):
|
|||
self.assertEqual(Account.objects.count(), 1)
|
||||
self.assertEqual(Asset.objects.count(), 1)
|
||||
self.assertEqual(AccountHolding.objects.count(), 1)
|
||||
self.assertEqual(AccountSnapshot.objects.count(), 2)
|
||||
self.assertEqual(AssetPriceSnapshot.objects.count(), 2)
|
||||
self.assertEqual(HoldingSnapshot.objects.count(), 2)
|
||||
|
||||
account = Account.objects.get()
|
||||
asset = Asset.objects.get()
|
||||
holding = AccountHolding.objects.get()
|
||||
latest_holding_snapshot = HoldingSnapshot.objects.filter(
|
||||
ingestion_run=second_run
|
||||
).get()
|
||||
|
||||
self.assertEqual(account.balance, 12500)
|
||||
self.assertEqual(asset.price, 205.55)
|
||||
self.assertEqual(holding.ammount, 12)
|
||||
self.assertEqual(latest_holding_snapshot.value, 12 * 205.55)
|
||||
self.assertEqual(second_run.accounts_processed, 1)
|
||||
self.assertEqual(second_run.assets_processed, 1)
|
||||
self.assertEqual(second_run.holdings_processed, 1)
|
||||
|
|
@ -285,6 +302,18 @@ class BrokerageIngestionServiceTests(TestCase):
|
|||
).count(),
|
||||
6,
|
||||
)
|
||||
self.assertEqual(
|
||||
AccountSnapshot.objects.filter(ingestion_run=run).count(),
|
||||
3,
|
||||
)
|
||||
self.assertEqual(
|
||||
AssetPriceSnapshot.objects.filter(ingestion_run=run).count(),
|
||||
6,
|
||||
)
|
||||
self.assertEqual(
|
||||
HoldingSnapshot.objects.filter(ingestion_run=run).count(),
|
||||
6,
|
||||
)
|
||||
|
||||
def test_sync_command_accepts_provider_identifier(self):
|
||||
random_connection = BrokerageConnection.objects.create(
|
||||
|
|
@ -331,3 +360,257 @@ class BrokerageIngestionServiceTests(TestCase):
|
|||
)
|
||||
|
||||
self.assertIn("matches multiple active connections", str(exc.exception))
|
||||
|
||||
def test_overview_page_renders_portfolio_history(self):
|
||||
BrokerageIngestionService(
|
||||
self.connection,
|
||||
adapter=FakeBrokerageAdapter(
|
||||
self.connection,
|
||||
snapshot=BrokerageSnapshot(
|
||||
accounts=[
|
||||
BrokerageAccountPayload(
|
||||
external_id="acct-1",
|
||||
name="Brokerage Account",
|
||||
number="1234",
|
||||
balance=10000,
|
||||
)
|
||||
],
|
||||
assets=[
|
||||
BrokerageAssetPayload(
|
||||
external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
price=200.00,
|
||||
)
|
||||
],
|
||||
holdings=[
|
||||
BrokerageHoldingPayload(
|
||||
external_id="holding-1",
|
||||
account_external_id="acct-1",
|
||||
asset_external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
amount=10,
|
||||
cost_basis=150.00,
|
||||
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).ingest()
|
||||
|
||||
response = self.client.get("/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Portfolio Value Over Time")
|
||||
self.assertContains(response, "Recent Sync Points")
|
||||
|
||||
def test_account_page_renders_account_history(self):
|
||||
run = BrokerageIngestionService(
|
||||
self.connection,
|
||||
adapter=FakeBrokerageAdapter(
|
||||
self.connection,
|
||||
snapshot=BrokerageSnapshot(
|
||||
accounts=[
|
||||
BrokerageAccountPayload(
|
||||
external_id="acct-1",
|
||||
name="Brokerage Account",
|
||||
number="1234",
|
||||
balance=10000,
|
||||
)
|
||||
],
|
||||
assets=[
|
||||
BrokerageAssetPayload(
|
||||
external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
price=200.00,
|
||||
)
|
||||
],
|
||||
holdings=[
|
||||
BrokerageHoldingPayload(
|
||||
external_id="holding-1",
|
||||
account_external_id="acct-1",
|
||||
asset_external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
amount=10,
|
||||
cost_basis=150.00,
|
||||
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).ingest()
|
||||
account = AccountSnapshot.objects.get(ingestion_run=run).account
|
||||
|
||||
response = self.client.get(f"/portfolio/{account.id}")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Account Value Over Time")
|
||||
self.assertContains(response, "Current Holdings")
|
||||
|
||||
def test_overview_range_selector_filters_to_recent_history(self):
|
||||
first_run = BrokerageIngestionService(
|
||||
self.connection,
|
||||
adapter=FakeBrokerageAdapter(
|
||||
self.connection,
|
||||
snapshot=BrokerageSnapshot(
|
||||
accounts=[
|
||||
BrokerageAccountPayload(
|
||||
external_id="acct-1",
|
||||
name="Brokerage Account",
|
||||
number="1234",
|
||||
balance=8000,
|
||||
)
|
||||
],
|
||||
assets=[
|
||||
BrokerageAssetPayload(
|
||||
external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
price=180.00,
|
||||
)
|
||||
],
|
||||
holdings=[
|
||||
BrokerageHoldingPayload(
|
||||
external_id="holding-1",
|
||||
account_external_id="acct-1",
|
||||
asset_external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
amount=10,
|
||||
cost_basis=150.00,
|
||||
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).ingest()
|
||||
second_run = BrokerageIngestionService(
|
||||
self.connection,
|
||||
adapter=FakeBrokerageAdapter(
|
||||
self.connection,
|
||||
snapshot=BrokerageSnapshot(
|
||||
accounts=[
|
||||
BrokerageAccountPayload(
|
||||
external_id="acct-1",
|
||||
name="Brokerage Account",
|
||||
number="1234",
|
||||
balance=12000,
|
||||
)
|
||||
],
|
||||
assets=[
|
||||
BrokerageAssetPayload(
|
||||
external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
price=210.00,
|
||||
)
|
||||
],
|
||||
holdings=[
|
||||
BrokerageHoldingPayload(
|
||||
external_id="holding-1",
|
||||
account_external_id="acct-1",
|
||||
asset_external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
amount=10,
|
||||
cost_basis=150.00,
|
||||
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).ingest()
|
||||
|
||||
old_timestamp = django_timezone.now() - timedelta(days=40)
|
||||
recent_timestamp = django_timezone.now() - timedelta(days=2)
|
||||
IngestionRun.objects.filter(pk=first_run.pk).update(started_at=old_timestamp)
|
||||
IngestionRun.objects.filter(pk=second_run.pk).update(started_at=recent_timestamp)
|
||||
AccountSnapshot.objects.filter(ingestion_run=first_run).update(snapshot_at=old_timestamp)
|
||||
AccountSnapshot.objects.filter(ingestion_run=second_run).update(snapshot_at=recent_timestamp)
|
||||
|
||||
response = self.client.get("/", {"range": "7d"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "value=\"7d\" selected")
|
||||
self.assertNotContains(response, old_timestamp.strftime("%Y-%m-%d %H:%M"))
|
||||
self.assertContains(response, recent_timestamp.strftime("%Y-%m-%d %H:%M"))
|
||||
|
||||
def test_account_range_selector_filters_account_history(self):
|
||||
first_run = BrokerageIngestionService(
|
||||
self.connection,
|
||||
adapter=FakeBrokerageAdapter(
|
||||
self.connection,
|
||||
snapshot=BrokerageSnapshot(
|
||||
accounts=[
|
||||
BrokerageAccountPayload(
|
||||
external_id="acct-1",
|
||||
name="Brokerage Account",
|
||||
number="1234",
|
||||
balance=9000,
|
||||
)
|
||||
],
|
||||
assets=[
|
||||
BrokerageAssetPayload(
|
||||
external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
price=180.00,
|
||||
)
|
||||
],
|
||||
holdings=[
|
||||
BrokerageHoldingPayload(
|
||||
external_id="holding-1",
|
||||
account_external_id="acct-1",
|
||||
asset_external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
amount=10,
|
||||
cost_basis=150.00,
|
||||
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).ingest()
|
||||
second_run = BrokerageIngestionService(
|
||||
self.connection,
|
||||
adapter=FakeBrokerageAdapter(
|
||||
self.connection,
|
||||
snapshot=BrokerageSnapshot(
|
||||
accounts=[
|
||||
BrokerageAccountPayload(
|
||||
external_id="acct-1",
|
||||
name="Brokerage Account",
|
||||
number="1234",
|
||||
balance=11000,
|
||||
)
|
||||
],
|
||||
assets=[
|
||||
BrokerageAssetPayload(
|
||||
external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
price=205.00,
|
||||
)
|
||||
],
|
||||
holdings=[
|
||||
BrokerageHoldingPayload(
|
||||
external_id="holding-1",
|
||||
account_external_id="acct-1",
|
||||
asset_external_id="asset-1",
|
||||
ticker="AAPL",
|
||||
amount=10,
|
||||
cost_basis=150.00,
|
||||
purchase_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).ingest()
|
||||
account = Account.objects.get()
|
||||
|
||||
old_timestamp = django_timezone.now() - timedelta(days=500)
|
||||
recent_timestamp = django_timezone.now() - timedelta(days=5)
|
||||
IngestionRun.objects.filter(pk=first_run.pk).update(started_at=old_timestamp)
|
||||
IngestionRun.objects.filter(pk=second_run.pk).update(started_at=recent_timestamp)
|
||||
AccountSnapshot.objects.filter(ingestion_run=first_run).update(snapshot_at=old_timestamp)
|
||||
AccountSnapshot.objects.filter(ingestion_run=second_run).update(snapshot_at=recent_timestamp)
|
||||
|
||||
response = self.client.get(f"/portfolio/{account.id}", {"range": "30d"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "value=\"30d\" selected")
|
||||
self.assertNotContains(response, old_timestamp.strftime("%Y-%m-%d %H:%M"))
|
||||
self.assertContains(response, recent_timestamp.strftime("%Y-%m-%d %H:%M"))
|
||||
|
|
|
|||
|
|
@ -1,18 +1,209 @@
|
|||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from .models import Account, AccountHolding
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Account, AccountHolding, AccountSnapshot, IngestionRun
|
||||
|
||||
|
||||
def _format_currency(value):
|
||||
return f"{value:,.2f}"
|
||||
|
||||
|
||||
DATE_RANGE_OPTIONS = [
|
||||
("7d", "7D"),
|
||||
("30d", "30D"),
|
||||
("90d", "90D"),
|
||||
("1y", "1Y"),
|
||||
("all", "All"),
|
||||
]
|
||||
|
||||
|
||||
def _get_selected_range(request: HttpRequest):
|
||||
allowed_values = {value for value, _ in DATE_RANGE_OPTIONS}
|
||||
selected_range = request.GET.get("range", "all")
|
||||
if selected_range not in allowed_values:
|
||||
return "all"
|
||||
return selected_range
|
||||
|
||||
|
||||
def _get_range_cutoff(selected_range: str):
|
||||
now = timezone.now()
|
||||
if selected_range == "7d":
|
||||
return now - timedelta(days=7)
|
||||
if selected_range == "30d":
|
||||
return now - timedelta(days=30)
|
||||
if selected_range == "90d":
|
||||
return now - timedelta(days=90)
|
||||
if selected_range == "1y":
|
||||
return now - timedelta(days=365)
|
||||
return None
|
||||
|
||||
|
||||
def _filter_timeline_by_range(timeline, selected_range: str):
|
||||
cutoff = _get_range_cutoff(selected_range)
|
||||
if cutoff is None:
|
||||
return timeline
|
||||
|
||||
filtered = [point for point in timeline if point["timestamp"] >= cutoff]
|
||||
if filtered:
|
||||
return filtered
|
||||
return timeline[-1:] if timeline else []
|
||||
|
||||
|
||||
def _build_chart_model(timeline, width=100, height=100):
|
||||
if not timeline:
|
||||
return {
|
||||
"path": "",
|
||||
"points": [],
|
||||
"min_label": _format_currency(0),
|
||||
"max_label": _format_currency(0),
|
||||
"start_label": "",
|
||||
"end_label": "",
|
||||
}
|
||||
|
||||
values = [point["value"] for point in timeline]
|
||||
min_value = min(values)
|
||||
max_value = max(values)
|
||||
span = max_value - min_value
|
||||
if span == 0:
|
||||
span = 1
|
||||
|
||||
chart_points = []
|
||||
coordinates = []
|
||||
point_count = len(timeline)
|
||||
for index, point in enumerate(timeline):
|
||||
if point_count == 1:
|
||||
x = width / 2
|
||||
y = height / 2
|
||||
else:
|
||||
x = (index / (point_count - 1)) * width
|
||||
normalized = (point["value"] - min_value) / span
|
||||
y = height - (normalized * height)
|
||||
|
||||
change = None
|
||||
change_display = "N/A"
|
||||
if index > 0:
|
||||
change = point["value"] - timeline[index - 1]["value"]
|
||||
sign = "+" if change >= 0 else "-"
|
||||
change_display = f"{sign}${_format_currency(abs(change))}"
|
||||
|
||||
chart_points.append(
|
||||
{
|
||||
"x": f"{x:.2f}",
|
||||
"y": f"{y:.2f}",
|
||||
"label": point["label"],
|
||||
"display_value": point["display_value"],
|
||||
"change_display": change_display,
|
||||
}
|
||||
)
|
||||
normalized = (point["value"] - min_value) / span
|
||||
coordinates.append(f"{x:.2f},{y:.2f}")
|
||||
|
||||
if len(coordinates) == 1:
|
||||
path = f"M 0 {height / 2:.2f} L {width:.2f} {height / 2:.2f}"
|
||||
else:
|
||||
path = "M " + " L ".join(coordinates)
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"points": chart_points,
|
||||
"min_label": _format_currency(min_value),
|
||||
"max_label": _format_currency(max_value),
|
||||
"start_label": timeline[0]["short_label"],
|
||||
"end_label": timeline[-1]["short_label"],
|
||||
}
|
||||
|
||||
|
||||
def _build_portfolio_timeline():
|
||||
latest_balances = {}
|
||||
timeline = []
|
||||
runs = IngestionRun.objects.order_by("started_at", "created_at").prefetch_related(
|
||||
"accountsnapshot_set__account"
|
||||
)
|
||||
|
||||
for run in runs:
|
||||
snapshots = list(run.accountsnapshot_set.all())
|
||||
if not snapshots:
|
||||
continue
|
||||
|
||||
for snapshot in snapshots:
|
||||
latest_balances[snapshot.account_id] = Decimal(snapshot.balance)
|
||||
|
||||
total_value = sum(latest_balances.values(), Decimal("0"))
|
||||
timestamp = run.started_at or run.created_at
|
||||
timeline.append(
|
||||
{
|
||||
"timestamp": timestamp,
|
||||
"label": timestamp.strftime("%Y-%m-%d %H:%M"),
|
||||
"short_label": timestamp.strftime("%b %d"),
|
||||
"value": float(total_value),
|
||||
"display_value": _format_currency(total_value),
|
||||
}
|
||||
)
|
||||
|
||||
return timeline
|
||||
|
||||
|
||||
def _build_account_timeline(account):
|
||||
snapshots = AccountSnapshot.objects.filter(account=account).order_by("snapshot_at")
|
||||
timeline = []
|
||||
|
||||
for snapshot in snapshots:
|
||||
timeline.append(
|
||||
{
|
||||
"timestamp": snapshot.snapshot_at,
|
||||
"label": snapshot.snapshot_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"short_label": snapshot.snapshot_at.strftime("%b %d"),
|
||||
"value": float(snapshot.balance),
|
||||
"display_value": _format_currency(snapshot.balance),
|
||||
}
|
||||
)
|
||||
|
||||
return timeline
|
||||
|
||||
# Create your views here.
|
||||
|
||||
def index(request):
|
||||
accounts = Account.objects.all().order_by("name")
|
||||
selected_range = _get_selected_range(request)
|
||||
portfolio_timeline = _filter_timeline_by_range(
|
||||
_build_portfolio_timeline(),
|
||||
selected_range,
|
||||
)
|
||||
latest_portfolio_point = portfolio_timeline[-1] if portfolio_timeline else None
|
||||
chart_model = _build_chart_model(portfolio_timeline)
|
||||
|
||||
accounts = Account.objects.all()
|
||||
context = {
|
||||
"accounts": accounts,
|
||||
"date_range_options": DATE_RANGE_OPTIONS,
|
||||
"selected_range": selected_range,
|
||||
"portfolio_timeline": portfolio_timeline,
|
||||
"latest_portfolio_point": latest_portfolio_point,
|
||||
"portfolio_chart": chart_model,
|
||||
}
|
||||
return render(request, "overviewbase.html", context)
|
||||
|
||||
return render(request, "overviewbase.html", {"accounts": accounts})
|
||||
|
||||
def portfolio(request, account_id):
|
||||
account = get_object_or_404(Account, pk=account_id)
|
||||
holdings = AccountHolding.objects.filter(account=account).select_related("asset")
|
||||
selected_range = _get_selected_range(request)
|
||||
account_timeline = _filter_timeline_by_range(
|
||||
_build_account_timeline(account),
|
||||
selected_range,
|
||||
)
|
||||
latest_account_point = account_timeline[-1] if account_timeline else None
|
||||
chart_model = _build_chart_model(account_timeline)
|
||||
|
||||
account = Account.objects.get(pk=account_id)
|
||||
holdings = AccountHolding.objects.filter(account = account)
|
||||
|
||||
return render(request, "portfolio.html", {"account": account, "holdings": holdings})
|
||||
context = {
|
||||
"account": account,
|
||||
"date_range_options": DATE_RANGE_OPTIONS,
|
||||
"selected_range": selected_range,
|
||||
"holdings": holdings,
|
||||
"account_timeline": account_timeline,
|
||||
"latest_account_point": latest_account_point,
|
||||
"account_chart": chart_model,
|
||||
}
|
||||
return render(request, "portfolio.html", context)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,114 @@
|
|||
<meta name="viewport" content="width=device-width, intitial-scale=1">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||
<title>{% block title %}{% endblock %} - Stonks</title>
|
||||
<style>
|
||||
body {
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
padding-top: 5.5rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #102a43;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #52606d;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-frame {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
|
||||
border: 1px solid #d8e0ea;
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chart-line {
|
||||
fill: none;
|
||||
stroke: #2f6fed;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chart-baseline {
|
||||
stroke: #cbd2d9;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chart-point {
|
||||
fill: #2f6fed;
|
||||
stroke: #ffffff;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.chart-axis {
|
||||
color: #52606d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.chart-axis-top,
|
||||
.chart-axis-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chart-axis-top {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.chart-axis-bottom {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.range-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.range-select {
|
||||
width: auto;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.timeline-list .list-group-item {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -29,7 +137,7 @@
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="container page-shell">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue