diff --git a/REPORT.md b/REPORT.md index e69de29b..3b62b84e 100644 --- a/REPORT.md +++ b/REPORT.md @@ -0,0 +1,70 @@ + +# 作業を確認したブラウザ + + User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 + +# 技術的な決定 + +## フロントエンドとバックエンドの分離 + +バックエンド処理はグラフ描画処理であり、それらは全て `chart` ディレクトリ配下にまとめた。 +フロントエンド処理は `views.py` にまとめ、`chart` 配下の機能を呼び出す形にすることでバックエンドと分離している。 + + +## モデル定義 + +Consumption テーブルのフィールド `consumption` の型は `FloatField` とした。 +理由は処理速度を優先するためである。 + +本アプリケーションでは数値の厳密さは重要ではないため、 +処理速度を落としてまで `DecimalField` を選択する必要は無いと考えられる。 + + +## インポート処理 + +データ量が多いため、バッチ処理でレコードの追加・更新を行うようにしている。 + +### 問題 + +時間の都合上、データベースに登録しようとしているCSVデータと同じ主キーのレコードが存在する場合、 +それらに対して一律で上書きするようにした。 +データベースへの負荷を考えると、登録前に各レコードのフィールドまでチェックして、 +フィールドが異なる場合のみ更新するようにしたほうが良い。 + +また、テストを書いてデータベース登録前の前処理が期待通り実装できているかチェックしたかったが、 +こちらも時間の都合上見送った。 + + +## データ取得 + +極力データベース側で処理を行い、処理後の結果をアプリケーションで取得するようにした。 +理由は、トラフィックの占有を防ぐためである。 + +例えば、消費量 (consumption) のデータ数は `ユーザ数 x 日数 x 48個/日` であり、テストデータだけでも50万個近くにのぼる。 +消費量の統計量を計算しようとしたとき、アプリケーション側でこれを行うと消費量の全データを取得する必要があり、トラフィックを占有してしまうことが予想される。 + +そのため、データベース側で先に処理を行い、処理後の結果を取得するようにした。 + +データベース側の処理負荷について考えると、 +今回のアプリケーションは大人数で使用するものでは無いため、 +負荷については問題にならないと判断した。 + +### 問題 + +データベース側で統計量 (中央値、10-90%-ile) の計算をするために生のSQLを発行している。 +保守性や脆弱性を考えると極力ORMの機能を活用すべきだと考えられる。 + +時間の都合上実装できなかったが、 +Aggregate を継承することで実装している例も見つかったため、Django の機能だけで実装することは可能かもしれない。 +https://gist.github.com/mekicha/b3d5e61683d5a6af642e4549eed95994 + + +## summary ページ + +ユーザーのリストアップはユーザー ID のみにした。 +理由は、トラフィックの占有を防ぐためと、描画の高速化のためである。 + +仮にページネーションと非同期処理を組み合わせて一部のユーザー情報だけを表示する方針であれば、 +ユーザーの全情報をテーブルで表示しても問題ないと考えられる。 + +しかし、今回は時間の都合上そこまで実装できなかったため、ユーザーIDのみを表示する方針とした。 diff --git a/dashboard/consumption/admin.py b/dashboard/consumption/admin.py index 13be29d9..81022d15 100644 --- a/dashboard/consumption/admin.py +++ b/dashboard/consumption/admin.py @@ -3,4 +3,8 @@ from django.contrib import admin +from consumption.models import Consumption, User + # Register your models here. +admin.site.register(User) +admin.site.register(Consumption) diff --git a/dashboard/consumption/chart/__init__.py b/dashboard/consumption/chart/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dashboard/consumption/chart/generate.py b/dashboard/consumption/chart/generate.py new file mode 100644 index 00000000..64cbb760 --- /dev/null +++ b/dashboard/consumption/chart/generate.py @@ -0,0 +1,163 @@ +import base64 +import io + +import matplotlib.pyplot as plt +import pandas as pd +from matplotlib.figure import Figure + +from consumption.chart.statistics import ( + get_area_daily_percentiles, + get_area_daily_total_consumptions, + get_daily_percentiles_for_all, + get_daily_total_consumptions_for_all, + get_user_area_daily_consumption_median, + get_user_daily_total_consumptions, +) + + +def plot_total_consumption(df: pd.DataFrame, percentiles: pd.DataFrame) -> Figure: + fig, ax1 = plt.subplots(figsize=(10, 5)) + + ax1.plot(df['date'], df['daily_total'], label='Total Consumption', color='blue') + ax1.set_title('Daily Consumption with 10-90 Percentile and Median') + ax1.set_xlabel('Date') + ax1.set_ylabel('Total Consumption', color='blue') + ax1.tick_params(axis='y', labelcolor='blue') + ax1.grid(True) + + ax2 = ax1.twinx() + if not percentiles.empty: + ax2.fill_between( + percentiles['date'], + percentiles['p10'], + percentiles['p90'], + color='green', + alpha=0.1, + label='10-90 Percentile', + ) + ax2.plot( + percentiles['date'], percentiles['p50'], linestyle='--', label='Median', color='green' + ) + ax2.set_ylabel('Percentiles and Median', color='green') + ax2.tick_params(axis='y', labelcolor='green') + + # 凡例の統合 + lines, labels = ax1.get_legend_handles_labels() + lines2, labels2 = ax2.get_legend_handles_labels() + ax1.legend(lines + lines2, labels + labels2, loc='upper left', bbox_to_anchor=(0.1, 0.9)) + + return fig + + +def plot_area_consumption(area_totals: pd.DataFrame, area_percentiles: pd.DataFrame) -> Figure: + fig, ax = plt.subplots(figsize=(10, 5)) + + colors = ['red', 'cyan', 'green', 'blue'] + color_index = 0 + ax2 = ax.twinx() + for area in area_totals['area'].unique(): + area_data_totals = area_totals[area_totals['area'] == area] + area_data_percentiles = area_percentiles[area_percentiles['area'] == area] + + ax.plot( + area_data_totals['date'], + area_data_totals['daily_total'], + label=f'{area} Total Consumption', + color=colors[color_index], + ) + ax2.fill_between( + area_data_percentiles['date'], + area_data_percentiles['p10'], + area_data_percentiles['p90'], + alpha=0.1, + label=f'{area} 10-90 Percentile', + color=colors[color_index], + ) + ax2.plot( + area_data_percentiles['date'], + area_data_percentiles['p50'], + linestyle='--', + label=f'{area} Median', + color=colors[color_index], + ) + + color_index = (color_index + 1) % len(colors) + + ax.set_title('Daily Consumption with 10-90 Percentile and Median by Area') + ax.set_xlabel('Date') + ax.set_ylabel('Total Consumption') + ax.grid(True) + + ax2.set_ylabel('Percentiles and Median') + lines, labels = ax.get_legend_handles_labels() + lines2, labels2 = ax2.get_legend_handles_labels() + ax.legend(lines + lines2, labels + labels2, loc='upper left', bbox_to_anchor=(0.1, 0.9)) + + return fig + + +def plot_user_and_area_consumption( + user_df: pd.DataFrame, area_df: pd.DataFrame, user_id: int +) -> Figure: + fig, ax = plt.subplots(figsize=(10, 5)) + ax.plot( + user_df['date'], user_df['daily_total'], label=f'User {user_id} Consumption', color='blue' + ) + ax.plot( + area_df['date'], + area_df['p50'], + label='Area Median Consumption', + color='red', + linestyle='--', + ) + ax.set_xlabel('Date') + ax.set_ylabel('Total Consumption') + ax.grid(True) + ax.legend(loc='upper left') + + return fig + + +def generate_daily_total_consumption_graph() -> str: + """日ごとの消費量の総量と、中央値と 10-90%-ile をプロットしたグラフを生成""" + df = get_daily_total_consumptions_for_all() + percentiles = get_daily_percentiles_for_all() + + with io.BytesIO() as buffer: + fig = plot_total_consumption(df, percentiles) + fig.savefig(buffer, format='png') + buffer.seek(0) + image_png = buffer.getvalue() + + graph = base64.b64encode(image_png).decode('utf-8') + return graph + + +def generate_daily_total_consumption_graph_by_area() -> str: + """エリア別に、日ごとの消費量の総量と、中央値と 10-90%-ile をプロットしたグラフを生成""" + df = get_area_daily_total_consumptions() + percentiles = get_area_daily_percentiles() + + with io.BytesIO() as buffer: + fig = plot_area_consumption(df, percentiles) + fig.savefig(buffer, format='png') + buffer.seek(0) + image_png = buffer.getvalue() + + graph = base64.b64encode(image_png).decode('utf-8') + return graph + + +def generate_user_consumption_graph(user_id: int) -> str: + """ユーザーごとの日ごとの消費量の総量と、エリアの中央値をプロットしたグラフを生成""" + user_df = get_user_daily_total_consumptions(user_id) + area_df = get_user_area_daily_consumption_median(user_id) + + with io.BytesIO() as buffer: + fig = plot_user_and_area_consumption(user_df, area_df, user_id) + fig.savefig(buffer, format='png') + buffer.seek(0) + image_png1 = buffer.getvalue() + + graph = base64.b64encode(image_png1).decode('utf-8') + return graph diff --git a/dashboard/consumption/chart/statistics.py b/dashboard/consumption/chart/statistics.py new file mode 100644 index 00000000..d00f9a49 --- /dev/null +++ b/dashboard/consumption/chart/statistics.py @@ -0,0 +1,204 @@ +import pandas as pd +from django.db import connection +from django.db.models import Sum +from django.db.models.functions import TruncDate + +from consumption.models import Consumption, User + + +def get_daily_total_consumptions_for_all() -> pd.DataFrame: + """全ユーザーの日ごとの消費量の合計を集計 + + Returns + ------- + pandas.DataFrame + columns=['date', 'daily_total'] + date: 日付, + daily_total: 全ユーザーの日ごとの消費量の合計 + """ + daily_total_consumption = ( + Consumption.objects.annotate(date=TruncDate('datetime')) + .values('date') + .annotate(daily_total=Sum('consumption')) + .order_by('date') + ) + return pd.DataFrame(daily_total_consumption) + + +def get_daily_percentiles_for_all() -> pd.DataFrame: + """日ごとの消費量の 10-90%-ile と中央値を集計 + + Returns + ------- + pandas.DataFrame + columns=['date', 'p10', 'p50', 'p90'] + date: 日付, + p10: 全ユーザーの日ごとの消費量の 10%-ile, + p50: 全ユーザーの日ごとの消費量の 50%-ile (median), + p90: 全ユーザーの日ごとの消費量の 90%-ile + """ + # モデルからテーブル名を取得 + consumption_table = Consumption._meta.db_table + + # NOTE: + query = f""" + WITH daily_totals AS ( + SELECT + user_id, + DATE_TRUNC('day', datetime) AS date, + SUM(consumption) AS daily_total + FROM {consumption_table} + GROUP BY user_id, date + ) + SELECT + date, + PERCENTILE_CONT(0.1) WITHIN GROUP (ORDER BY daily_total) AS p10, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY daily_total) AS p50, + PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY daily_total) AS p90 + FROM daily_totals + GROUP BY date + ORDER BY date; + """ + + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + + return pd.DataFrame(rows, columns=['date', 'p10', 'p50', 'p90']) + + +def get_area_daily_total_consumptions() -> pd.DataFrame: + """エリア別に日ごとの消費量の合計を集計 + + Returns + ------- + pandas.DataFrame + columns=['area', date', 'daily_total'] + area: エリア名 + date: 日付, + daily_total: 全ユーザーの日ごとの消費量の合計 + """ + area_daily_totals = ( + Consumption.objects.select_related('user') + .annotate(date=TruncDate('datetime')) + .values('user__area', 'date') + .annotate(daily_total=Sum('consumption')) + .order_by('user__area', 'date') + ) + df = pd.DataFrame(area_daily_totals) + df.rename(columns={'user__area': 'area'}, inplace=True) + return df + + +def get_area_daily_percentiles() -> pd.DataFrame: + """エリア別に日ごとの消費量の 10-90%-ile と中央値を集計 + + Returns + ------- + pandas.DataFrame + columns=['area', 'date', 'p10', 'p50', 'p90'] + area: エリア名, + date: 日付, + p10: 全ユーザーの日ごとの消費量の 10%-ile, + p50: 全ユーザーの日ごとの消費量の 50%-ile (median), + p90: 全ユーザーの日ごとの消費量の 90%-ile + """ + # モデルからテーブル名を取得 + consumption_table = Consumption._meta.db_table + user_table = User._meta.db_table + + query = f""" + WITH daily_totals AS ( + SELECT + area, + DATE_TRUNC('day', datetime) AS date, + SUM(consumption) AS daily_total + FROM {consumption_table} AS c + INNER JOIN {user_table} AS u ON c.user_id = u.id + GROUP BY area, user_id, date + ) + SELECT + area, + date, + PERCENTILE_CONT(0.1) WITHIN GROUP (ORDER BY daily_total) AS p10, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY daily_total) AS p50, + PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY daily_total) AS p90 + FROM daily_totals + GROUP BY area, date + ORDER BY area, date; + """ + + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + + return pd.DataFrame(rows, columns=['area', 'date', 'p10', 'p50', 'p90']) + + +def get_user_daily_total_consumptions(user_id: int) -> pd.DataFrame: + """特定ユーザーの日ごとの消費量の合計を集計 + + Params + ------ + user_id : int + 対象ユーザのID + + Returns + ------- + pandas.DataFrame + columns=['date', 'daily_total'] + date: 日付, + daily_total: ユーザーの日ごとの消費量の合計 + """ + user_daily_totals = ( + Consumption.objects.filter(user_id=user_id) + .annotate(date=TruncDate('datetime')) + .values('date') + .annotate(daily_total=Sum('consumption')) + .order_by('date') + ) + return pd.DataFrame(user_daily_totals) + + +def get_user_area_daily_consumption_median(user_id: int) -> pd.DataFrame: + """特定ユーザが属するエリアの日ごとの消費量の中央値を集計 + + Params + ------ + user_id : int + 対象ユーザのID + + Returns + ------- + pandas.DataFrame + columns=['date', 'p10', 'p50', 'p90'] + date: 日付, + p50: 全ユーザーの日ごとの消費量の 50%-ile (median) + """ + # モデルからテーブル名を取得 + consumption_table = Consumption._meta.db_table + user_table = User._meta.db_table + + query = f""" + WITH daily_totals AS ( + SELECT + DATE_TRUNC('day', datetime) AS date, + SUM(consumption) AS daily_total + FROM {consumption_table} AS c + INNER JOIN {user_table} AS u ON c.user_id = u.id + WHERE u.area = (SELECT area FROM {user_table} WHERE id = %s) + GROUP BY user_id, date + ) + SELECT + date, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY daily_total) AS p50 + FROM daily_totals + GROUP BY date + ORDER BY date; + """ + + with connection.cursor() as cursor: + cursor.execute(query, [user_id]) + area_rows = cursor.fetchall() + + return pd.DataFrame(area_rows, columns=['date', 'p50']) diff --git a/dashboard/consumption/management/commands/import.py b/dashboard/consumption/management/commands/import.py index 9593d6a5..36941d46 100644 --- a/dashboard/consumption/management/commands/import.py +++ b/dashboard/consumption/management/commands/import.py @@ -1,8 +1,198 @@ +from pathlib import Path +from typing import Any, Iterable + +import pandas as pd +from django.conf import settings from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils.timezone import make_aware +from pandas.api.types import is_float_dtype + +from consumption.models import Consumption, User + + +def make_user_list_to_create_and_update( + df: pd.DataFrame, existing_users: dict[int, User] +) -> Iterable[list[User]]: + """User テーブルに登録するユーザーリストと、更新するユーザーリストを作成""" + users_to_create = [] + users_to_update = [] + + for _, row in df.iterrows(): + user_id = row['id'] + if user_id in existing_users: + user = existing_users[user_id] + user.area = row['area'] + user.tariff = row['tariff'] + users_to_update.append(user) + else: + users_to_create.append(User(id=row['id'], area=row['area'], tariff=row['tariff'])) + + return users_to_create, users_to_update + + +def import_user_data(csv_file_path, batch_size=1000): + """ユーザー情報を CSV から User テーブルへインポート""" + df = pd.read_csv(csv_file_path) + + # 列名のチェック + if not all(column in df.columns for column in ['id', 'area', 'tariff']): + raise ValueError("CSV file must contain 'id', 'area', and 'tariff' columns") + + existing_users = User.objects.in_bulk(df['id'].tolist()) + users_to_create, users_to_update = make_user_list_to_create_and_update(df, existing_users) + + with transaction.atomic(): + for i in range(len(users_to_create) // batch_size + 1): + # IndexError が発生しないように処理をスキップ + if i * batch_size == len(users_to_create): + continue + User.objects.bulk_create(users_to_create[i * batch_size : (i + 1) * batch_size]) + + for i in range(len(users_to_update) // batch_size + 1): + # IndexError が発生しないように処理をスキップ + if i * batch_size == len(users_to_update): + continue + User.objects.bulk_update( + users_to_update[i * batch_size : (i + 1) * batch_size], ['area', 'tariff'] + ) + + +def load_consumption_data(consumption_dir: Path) -> pd.DataFrame: + """消費量の情報を複数の CSV から取得して1つの pd.DataFrame に集約""" + + all_files = [consumption_dir / f for f in consumption_dir.glob('*.csv')] + if not all_files: + raise Exception(f'No CSV files found in {consumption_dir}') + + # 全てのCSVファイルをロード + all_dfs = [] + for file in all_files: + user_id = file.stem + try: + int(user_id) + except ValueError: + raise ValueError(f'Invalid user_id in filename: {file}') + + df = pd.read_csv(file) + df['user_id'] = int(user_id) + all_dfs.append(df) + + combined_df = pd.concat(all_dfs, ignore_index=True) + + # 列名のチェック + required_columns = {'user_id', 'datetime', 'consumption'} + if not required_columns.issubset(combined_df.columns): + raise ValueError(f'CSV file must contain columns: {required_columns}') + + # datetimeのパース + combined_df['datetime'] = pd.to_datetime(combined_df['datetime']) + # CSVデータにタイムゾーンの情報が含まれていない場合、UTCとして扱う + if combined_df['datetime'].dt.tz is None: + combined_df['datetime'] = combined_df['datetime'].apply(make_aware) + + # 重複の削除 + combined_df = combined_df.drop_duplicates(subset=['user_id', 'datetime'], keep='last') + + # consumption が float か確認 + if not is_float_dtype(combined_df['consumption']): + try: + combined_df['consumption'] = combined_df['consumption'].astype('float64') + except ValueError as e: + raise ValueError( + f'{e}. Correct the aforementioned characters in the consumption CSV to the appropriate numerical values.' + ) + return combined_df + + +def make_consumption_data_list_to_create_and_update( + combined_df: pd.DataFrame, + existing_consumptions: dict[Any, Consumption], + existing_users: dict[int, User], +) -> Iterable[list[Consumption]]: + """Consumption テーブルに登録する消費量のリストと、更新する消費量のリストを作成""" + consumption_data_to_create = [] + consumption_data_to_update = [] + + for _, row in combined_df.iterrows(): + user_id = row['user_id'] + key = (user_id, row['datetime']) + if key in existing_consumptions: + consumption_data = existing_consumptions[key] + consumption_data.consumption = row['consumption'] + consumption_data_to_update.append(consumption_data) + else: + consumption_data_to_create.append( + Consumption( + user=existing_users[user_id], + datetime=row['datetime'], + consumption=row['consumption'], + ) + ) + + return consumption_data_to_create, consumption_data_to_update + + +def import_all_consumption_data(consumption_dir: Path, batch_size=1000): + """消費量の情報を複数の CSV から Consumption テーブルへインポート""" + combined_df = load_consumption_data(consumption_dir) + + # 全ユーザIDを取得 + user_ids = combined_df['user_id'].unique().tolist() + existing_users = User.objects.in_bulk(user_ids) + + # 登録されていないユーザIDをチェック + for user_id in user_ids: + if int(user_id) not in existing_users: + raise ValueError(f'User ID {user_id} not found in database') + + # 既存の消費データを一括取得 + existing_data = Consumption.objects.filter( + user_id__in=user_ids, datetime__in=combined_df['datetime'].tolist() + ) + existing_consumptions = {(data.user.id, data.datetime): data for data in existing_data} + + consumption_data_to_create, consumption_data_to_update = ( + make_consumption_data_list_to_create_and_update( + combined_df=combined_df, + existing_consumptions=existing_consumptions, + existing_users=existing_users, + ) + ) + + with transaction.atomic(): + for i in range(len(consumption_data_to_create) // batch_size + 1): + # IndexError が発生しないように処理をスキップ + if i * batch_size == len(consumption_data_to_create): + continue + Consumption.objects.bulk_create( + consumption_data_to_create[i * batch_size : (i + 1) * batch_size] + ) + + for i in range(len(consumption_data_to_update) // batch_size + 1): + # IndexError が発生しないように処理をスキップ + if i * batch_size == len(consumption_data_to_update): + continue + Consumption.objects.bulk_update( + consumption_data_to_update[i * batch_size : (i + 1) * batch_size], ['consumption'] + ) class Command(BaseCommand): help = 'import data' def handle(self, *args, **options): - print("Implement me!") + data_dir = Path(settings.BASE_DIR).parent / 'data' + if not data_dir.exists(): + raise FileNotFoundError( + f'`{data_dir}` not found. Please place the directory containing the CSV files.' + ) + + import_user_data(data_dir / 'user_data.csv') + + consumption_dir = data_dir / 'consumption' + if not consumption_dir.exists(): + raise FileNotFoundError( + f'`{consumption_dir}` not found. Please place the directory containing the CSV files.' + ) + import_all_consumption_data(consumption_dir) diff --git a/dashboard/consumption/models.py b/dashboard/consumption/models.py index 1dfab760..a1e4b94e 100644 --- a/dashboard/consumption/models.py +++ b/dashboard/consumption/models.py @@ -2,5 +2,57 @@ from __future__ import unicode_literals from django.db import models +from django.utils import timezone -# Create your models here. + +class QuerySet(models.QuerySet): + """ + queryset.update()で更新日時を記録するhook + + ref: https://scrapbox.io/shimizukawa/django_bulk_update_%E6%99%82%E3%81%ABupdated_at%E3%82%92%E6%9B%B4%E6%96%B0%E3%81%99%E3%82%8B + """ + + # bulk update SQLの発行元メソッド + def update(self, **kwargs) -> int: + if 'updated_at' not in kwargs: + kwargs['updated_at'] = timezone.now() + return super().update(**kwargs) + + +class BaseModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True, blank=True, null=True) + + # bulk_update では Model.save() が呼ばれないため、updated_at が更新されない + # この対処として、objects を差し替える + # ref: https://scrapbox.io/shimizukawa/django_bulk_update_%E6%99%82%E3%81%ABupdated_at%E3%82%92%E6%9B%B4%E6%96%B0%E3%81%99%E3%82%8B + objects = models.manager.BaseManager.from_queryset(QuerySet)() + + class Meta: + abstract = True + + +class User(BaseModel): + id = models.IntegerField(primary_key=True, help_text='ユーザID') + area = models.CharField(max_length=3, help_text='エリア') + tariff = models.CharField(max_length=3, help_text='関税') + + def __str__(self): + return f'User {self.id} - Area: {self.area} - Tariff: {self.tariff}' + + +class Consumption(models.Model): + id = models.AutoField(primary_key=True) + user = models.ForeignKey( + User, on_delete=models.PROTECT, help_text='この消費データに関連するユーザ' + ) + datetime = models.DateTimeField(help_text='消費データの日時') + consumption = models.FloatField(help_text='30分ごとのエネルギー消費量') + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['user', 'datetime'], name='unique_user_datetime') + ] + + def __str__(self): + return f'User {self.user.id} - Datetime: {self.datetime} - Consumption: {self.consumption}' diff --git a/dashboard/consumption/templates/consumption/detail.html b/dashboard/consumption/templates/consumption/detail.html index 22a36845..2fcd1558 100644 --- a/dashboard/consumption/templates/consumption/detail.html +++ b/dashboard/consumption/templates/consumption/detail.html @@ -1,5 +1,13 @@ {% extends 'consumption/layout.html' %} -{% block content %} +{% block subtitle %} +Details for {{ user_info }} +{% endblock %} -{% endblock %} \ No newline at end of file +{% block content %} +
+ {{ user_info }} +
+