├── .gitignore ├── LICENSE ├── README.md ├── accounts ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── models.py ├── pipelines.py ├── templates │ ├── profile.html │ ├── register.html │ └── registration │ │ ├── logged_out.html │ │ └── login.html ├── tests.py ├── urls.py └── views.py ├── manage.py ├── requirements.txt ├── tracker ├── admin.py ├── apps.py ├── filters.py ├── forms.py ├── migrations │ └── __init__.py ├── models.py ├── static │ ├── imgs │ │ ├── icon_google.png │ │ └── landing-bg.jpg │ ├── main.css │ └── main.js ├── tables.py ├── templates │ ├── analytics │ │ └── index.html │ ├── landing │ │ └── index.html │ └── tracker │ │ ├── base.html │ │ ├── dashboard.html │ │ ├── expense_form.html │ │ ├── form_template.html │ │ └── index.html ├── templatetags │ ├── __init__.py │ └── month_labels.py ├── tests.py ├── urls.py ├── utils.py └── views.py └── website ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | migrations 3 | db.sqlite3 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Squirrel 2 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fwingkwong%2Fsquirrel.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fwingkwong%2Fsquirrel?ref=badge_shield) 3 | 4 | ## About 5 | A responsive web application for expense tracking and analytics platform powered by Django 6 | 7 | ## Installed apps: 8 | * django 2.0.8 9 | * django.contrib.admin 10 | * django.contrib.auth 11 | * django.contrib.contenttypes 12 | * django.contrib.sessions 13 | * django.contrib.messages 14 | * django.contrib.staticfiles 15 | * tracker.apps.TrackerConfig 16 | * django_tables2 17 | * django_filters 18 | * crispy_forms 19 | * tracker.templatetags.month_labels 20 | * social_django 21 | 22 | ## Configured URLs: 23 | 24 | * ``/`` 25 | * ``/tracker`` 26 | * ``/tracker/add`` 27 | * ``/tracker/update/{id}`` 28 | * ``/analytics`` 29 | * ``/analytics/{year}`` 30 | * ``/analytics/{year}/{month}`` 31 | * ``/analytics/{year}/{month}/{day}`` 32 | * ``/accounts/register`` 33 | * ``/accounts/login`` 34 | * ``/accounts/logout`` 35 | * ``/accounts/profile/{id}`` 36 | * ``/dashboard`` 37 | * ``/auth/`` 38 | * ``/admin/`` 39 | 40 | ## Templates: 41 | 42 | Landing: 43 | * ``landing/index.html`` 44 | 45 | Tracker: 46 | * ``tracker/expense_form.html`` 47 | * ``tracker/from_template.html`` 48 | * ``tracker/header.html`` 49 | * ``tracker/index.html`` 50 | * ``analytics/index.html`` 51 | 52 | Accounts: 53 | * ``registration/login.html`` 54 | * ``registration/logged_out.html`` 55 | 56 | ## Features: 57 | 58 | * Responsive 59 | * Expense Overview with filtering 60 | * Expense Addition 61 | * Expense Analytics by year, month or day 62 | * Google OAuth2 63 | 64 | ## Prerequisites 65 | 66 | - Python >= 3.5 67 | - pip3 68 | 69 | ## Setup Your Environment 70 | 1. Fork this project 71 | 2. Install from the given requirements file. 72 | ```bash 73 | pip3 install -r requirements.txt 74 | ``` 75 | 3. Make Migrations 76 | ```bash 77 | python3 manage.py makemigrations 78 | ``` 79 | 4. Migrate 80 | ```bash 81 | python3 manage.py migrate 82 | ``` 83 | 5. Run 84 | ```bash 85 | python3 manage.py runserver 86 | ``` 87 | 88 | ## Setup Your Google OAuth 89 | 1. Go to Google Developers Console(https://console.developers.google.com/apis/library?project=_) and create a new project 90 | 2. Go to credentials tab 91 | 3. Create Credentials and choose OAuth Client ID 92 | 4. Select Web Application and enter any name in 'Product name shown to users' under OAuth Consent Screen tab 93 | 5. Set `http://127.0.0.1:8000/auth/complete/google-oauth2/` in Authorized redirect URIs 94 | 6. Under APIs and services tab, click on Google+ API and then click Enable 95 | 7. Copy the Client ID and Client Secret Under settings.py 96 | ```bash 97 | SOCIAL_AUTH_GOOGLE_OAUTH2_KEY='' # ClientKey 98 | SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=''# SecretKey 99 | ``` 100 | 101 | ## Authors 102 | 103 | * **Wing Kam WONG** - [@wingkwong](https://github.com/wingkwong) 104 | 105 | ## Contributor 106 | 107 | * **Tushar Kapoor** - [@TusharKapoor23](https://github.com/TusharKapoor23) 108 | 109 | ## License 110 | 111 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 112 | 113 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fwingkwong%2Fsquirrel.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fwingkwong%2Fsquirrel?ref=badge_large) 114 | -------------------------------------------------------------------------------- /accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wingkwong/squirrel/0841d68f744b22ed2ba85917257429359a498f9a/accounts/__init__.py -------------------------------------------------------------------------------- /accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | from django.contrib.auth.models import User 4 | from .models import Profile 5 | 6 | 7 | class ProfileInline(admin.StackedInline): 8 | model = Profile 9 | can_delete = False 10 | verbose_name_plural = 'Profile' 11 | fk_name = 'user' 12 | 13 | 14 | class CustomUserAdmin(UserAdmin): 15 | inlines = (ProfileInline, ) 16 | 17 | def get_inline_instances(self, request, obj=None): 18 | if not obj: 19 | return list() 20 | return super(CustomUserAdmin, self).get_inline_instances(request, obj) 21 | 22 | 23 | admin.site.unregister(User) 24 | admin.site.register(User, CustomUserAdmin) -------------------------------------------------------------------------------- /accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = 'accounts' 6 | 7 | 8 | -------------------------------------------------------------------------------- /accounts/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.forms import UserCreationForm 3 | from django.contrib.auth.models import User 4 | 5 | 6 | class RegisterForm(UserCreationForm): 7 | dob = forms.DateField(help_text='Required. Format: YYYY-MM-DD') 8 | 9 | class Meta: 10 | model = User 11 | fields = ('username', 'dob', 'email', 'password1', 'password2' ) -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | from django.db.models.signals import post_save 4 | from django.dispatch import receiver 5 | 6 | 7 | class Profile(models.Model): 8 | user = models.OneToOneField(User, on_delete=models.CASCADE) 9 | avatar = models.ImageField(upload_to='profiles') 10 | dob = models.DateField(null=True, blank=True) 11 | 12 | 13 | @receiver(post_save, sender=User) 14 | def update_user_profile(sender, instance, created, **kwargs): 15 | if created: 16 | Profile.objects.create(user=instance) 17 | instance.profile.save() 18 | 19 | 20 | @receiver(post_save, sender=User) 21 | def save_user_profile(sender, instance, **kwargs): 22 | instance.profile.save() -------------------------------------------------------------------------------- /accounts/pipelines.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | 4 | # Setting avatar after OAuth Login 5 | def saveAvatar(backend, strategy, details, response, 6 | user=None, *args, **kwargs): 7 | url = None 8 | if backend.name == 'google-oauth2': 9 | url = response['image'].get('url') 10 | ext = url.split('.')[-1] 11 | if url: 12 | try: 13 | user = User.objects.get(username=details['username']) 14 | user.profile.avatar = url 15 | user.save() 16 | except User.DoesNotExist: 17 | user = None 18 | -------------------------------------------------------------------------------- /accounts/templates/profile.html: -------------------------------------------------------------------------------- 1 | 2 | {% include 'tracker/base.html' %} 3 | 4 | {% block body %} 5 | 6 | 7 |
8 |
9 |
10 |
11 |
12 | {% if profile.avatar != ''%} 13 | 14 | {% else %} 15 | 16 | 17 | {% endif%} 18 | {{profile.user}} 19 |
20 | 26 |
27 |
28 | 29 |
30 |
31 | 32 |
33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | {%endblock%} -------------------------------------------------------------------------------- /accounts/templates/register.html: -------------------------------------------------------------------------------- 1 | {% include 'tracker/base.html' %} 2 | 3 | {% block body %} 4 |

Register

5 |
6 | {% csrf_token %} 7 | {{ form.as_p }} 8 | 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /accounts/templates/registration/logged_out.html: -------------------------------------------------------------------------------- 1 | 2 | {% block content %} 3 |

Logged out!

4 | 5 | Click here to login again. 6 | {% endblock %} -------------------------------------------------------------------------------- /accounts/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | 2 | {% include 'tracker/base.html' %} 3 | 4 | 5 | {% block body %} 6 |
7 | {% if form.errors %} 8 |

Your username and password didn't match. Please try again.

9 | {% endif %} 10 | 11 | {% if next %} 12 | {% if user.is_authenticated %} 13 |

Your account doesn't have access to this page. To proceed, 14 | please login with an account that has access.

15 | {% else %} 16 |

Please login to see this page.

17 | {% endif %} 18 | {% endif %} 19 |
20 | {% csrf_token %} 21 |
22 | Username 23 | {{ form.username }} 24 |
25 |
26 | Password 27 | {{ form.password }} 28 |
29 | 30 |
31 | 32 | 33 |
34 |
35 | 36 |
37 |

-- OR --

38 |

39 | 40 | 43 | 44 |

45 | 46 |

Forget Password?

47 |

Don't have an account? Register here.

48 |
49 | {% endblock %} -------------------------------------------------------------------------------- /accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.contrib.auth.decorators import login_required 3 | from accounts import views 4 | 5 | 6 | urlpatterns = [ 7 | path('register/', views.register, name='register'), 8 | path('profile/', login_required(views.ProfileView.profile), name='profile') 9 | ] 10 | -------------------------------------------------------------------------------- /accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import login, authenticate 2 | from django.shortcuts import render, redirect 3 | from django.contrib.auth.models import User 4 | from .forms import RegisterForm 5 | 6 | 7 | def register(request): 8 | if request.method == 'POST': 9 | form = RegisterForm(request.POST) 10 | if form.is_valid(): 11 | user = form.save() 12 | user.refresh_from_db() 13 | user.profile.dob = form.cleaned_data.get('dob') 14 | user.save() 15 | raw_password = form.cleaned_data.get('password1') 16 | user = authenticate(username=user.username, password=raw_password) 17 | login(request, user) 18 | return redirect('tracker:expense') 19 | else: 20 | form = RegisterForm() 21 | return render(request, 'register.html', {'form': form}) 22 | 23 | 24 | class ProfileView(): 25 | def profile(request): 26 | # Getting current user 27 | user = User.objects.get(username=request.user) 28 | # Passing User Profile to Profile Page 29 | context = { 30 | 'profile': user.profile 31 | } 32 | return render(request, 'profile.html', context) -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django_tables2==1.19.0 2 | django_filter==1.1.0 3 | Django==2.0.8 4 | django_crispy_forms==1.7.2 5 | django_filters==0.2.1 6 | social-auth-app-django 7 | Pillow==5.2.0 8 | -------------------------------------------------------------------------------- /tracker/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Expense 3 | 4 | admin.site.register(Expense) 5 | -------------------------------------------------------------------------------- /tracker/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TrackerConfig(AppConfig): 5 | name = 'tracker' 6 | -------------------------------------------------------------------------------- /tracker/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from .models import Expense 3 | 4 | 5 | class ExpenseFilter(django_filters.FilterSet): 6 | description = django_filters.CharFilter(lookup_expr='icontains') 7 | type = django_filters.CharFilter(lookup_expr='icontains') 8 | payment = django_filters.CharFilter(lookup_expr='icontains') 9 | amount = django_filters.CharFilter(lookup_expr='icontains') 10 | 11 | class Meta: 12 | model = Expense 13 | fields = ( 14 | 'date', 15 | 'description', 16 | 'type', 17 | 'payment', 18 | 'amount' 19 | ) -------------------------------------------------------------------------------- /tracker/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from crispy_forms.helper import FormHelper 3 | from crispy_forms.layout import Layout, Field, ButtonHolder, Submit 4 | from django.forms import ModelForm 5 | from .models import Expense 6 | 7 | class ExpenseTableHelper(FormHelper): 8 | date = forms.DateField( 9 | widget=forms.TextInput( 10 | attrs={'type': 'date'} 11 | ) 12 | ) 13 | form_method = 'GET' 14 | layout = Layout( 15 | Field('date', placeholder='YYYY-MM-DD'), 16 | Field('description'), 17 | Field('type'), 18 | Field('payment'), 19 | Field('amount'), 20 | Submit('submit', 'Filter'), 21 | ) 22 | 23 | 24 | class DateInput(forms.DateInput): 25 | input_type = 'date' 26 | 27 | 28 | class AddExpenseForm(ModelForm): 29 | class Meta: 30 | model = Expense 31 | fields = [ 32 | 'date', 33 | 'description', 34 | 'type', 35 | 'payment', 36 | 'amount' 37 | ] 38 | widgets = { 39 | 'date': DateInput(), 40 | } -------------------------------------------------------------------------------- /tracker/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wingkwong/squirrel/0841d68f744b22ed2ba85917257429359a498f9a/tracker/migrations/__init__.py -------------------------------------------------------------------------------- /tracker/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | 5 | class Expense(models.Model): 6 | date = models.DateField() 7 | description = models.CharField(max_length=1000, null=True) 8 | type = models.CharField(max_length=30) 9 | payment = models.CharField(max_length=30) 10 | amount = models.FloatField() 11 | created_by = models.CharField(max_length=100) 12 | created_at = models.DateField() 13 | 14 | class Meta: 15 | verbose_name = 'Expense' 16 | verbose_name_plural = 'Expenses' 17 | ordering = ['-id'] 18 | 19 | def get_absolute_url(self): 20 | return reverse('tracker:expense') 21 | -------------------------------------------------------------------------------- /tracker/static/imgs/icon_google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wingkwong/squirrel/0841d68f744b22ed2ba85917257429359a498f9a/tracker/static/imgs/icon_google.png -------------------------------------------------------------------------------- /tracker/static/imgs/landing-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wingkwong/squirrel/0841d68f744b22ed2ba85917257429359a498f9a/tracker/static/imgs/landing-bg.jpg -------------------------------------------------------------------------------- /tracker/static/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | Override 3 | */ 4 | 5 | .card-panel, 6 | .collection a.collection-item, 7 | .navbar { 8 | background-color: #1a1a1f; 9 | color: #ffffff; 10 | } 11 | 12 | nav a, 13 | nav ul a, 14 | tr a, 15 | .login a, 16 | .pagination li a { 17 | color: #ffffff; 18 | } 19 | 20 | nav ul a, 21 | .input-field>label, 22 | label{ 23 | font-size: 18px; 24 | } 25 | 26 | td, th { 27 | padding: 0px; 28 | } 29 | 30 | .container { 31 | margin-top: 20px; 32 | } 33 | 34 | .btn, .btn-large, .btn-small, 35 | .btn:hover, .btn-large:hover, .btn-small:hover, 36 | .btn:focus, .btn-large:focus, .btn-small:focus{ 37 | background-color: #ffffff; 38 | color: #1a1a1f; 39 | } 40 | 41 | input, 42 | input:focus{ 43 | color: #ffffff; 44 | border-bottom: 1px solid #ffffff !important; 45 | } 46 | .card { 47 | background: #1a1a1f; 48 | color: #ffffff; 49 | } 50 | 51 | .collection .collection-item, 52 | .collection .collection-item.active { 53 | background-color: #1a1a1f; 54 | color: #ffffff; 55 | } 56 | 57 | .collection a.collection-item:not(.active):hover, 58 | .collection a.collection-item { 59 | background-color: #323237; 60 | color: #ffffff; 61 | } 62 | 63 | span.badge { 64 | color: #ffffff; 65 | } 66 | 67 | /* Navbar */ 68 | .navbar-brand{ 69 | padding: 30px 30px 30px 15px; 70 | font-size: 25px; 71 | } 72 | /* Landing */ 73 | /*-------------------------------------------------------------- #General --------------------------------------------------------------*/ 74 | body{ 75 | background-color: #323237; 76 | color: #ffffff; 77 | font-size: 18px; 78 | font-family: 'Josefin Sans', Helvetica, Arial, sans-serif; 79 | overflow-x:hidden; 80 | } 81 | a{ 82 | color:#ffffff; 83 | } 84 | /* a:hover,a:active,a:focus{ 85 | color:#1a1a1f; 86 | outline:none; 87 | text-decoration:none; 88 | } 89 | */ 90 | p{ 91 | padding:0; 92 | margin:30px 0px; 93 | } 94 | h1,h2,h3,h4,h5,h6{ 95 | font-family:"Montserrat",sans-serif; 96 | font-weight:400; 97 | padding:0; 98 | } 99 | 100 | .text-center{ 101 | text-align: center; 102 | } 103 | 104 | .features-row{ 105 | padding: 20px 0px; 106 | } 107 | 108 | /*-------------------------------------------------------------- #IntroSection --------------------------------------------------------------*/ 109 | #intro{ 110 | width:100%; 111 | height:100%; 112 | background-color:#1a1a1f; 113 | position:relative; 114 | margin-top:-20px; 115 | } 116 | @media(min-width:1024px){ 117 | #intro{ 118 | background-attachment:fixed; 119 | } 120 | } 121 | #intro .intro-text{ 122 | position:absolute; 123 | left:0; 124 | top:100px; 125 | right:0; 126 | height:calc(80% -20px); 127 | display:flex; 128 | align-items:center; 129 | justify-content:center; 130 | text-align:center; 131 | flex-direction:column; 132 | } 133 | 134 | #intro p{ 135 | margin-bottom:20px; 136 | padding:15px; 137 | font-size:24px; 138 | } 139 | @media(max-width:768px){ 140 | #intro p{ 141 | font-size:18px; 142 | line-height:24px; 143 | margin-bottom:20px; 144 | } 145 | } 146 | 147 | /* 148 | Login 149 | */ 150 | 151 | .login{ 152 | margin-top: -20px; 153 | padding: 40px; 154 | text-align: center; 155 | } 156 | 157 | .loginBtn { 158 | box-sizing: border-box; 159 | position: relative; 160 | margin: 0.2em; 161 | padding: 0 15px 0 46px; 162 | border: none; 163 | text-align: left; 164 | line-height: 34px; 165 | white-space: nowrap; 166 | border-radius: 0.2em; 167 | font-size: 16px; 168 | color: #1a1a1f; 169 | } 170 | .loginBtn:before { 171 | content: ""; 172 | box-sizing: border-box; 173 | position: absolute; 174 | top: 0; 175 | left: 0; 176 | width: 34px; 177 | height: 100%; 178 | } 179 | .loginBtn:focus { 180 | outline: none; 181 | } 182 | .loginBtn:active { 183 | box-shadow: inset 0 0 0 32px rgba(0,0,0,0.1); 184 | } 185 | 186 | 187 | /* Google */ 188 | .loginBtn--google { 189 | background: #DD4B39; 190 | } 191 | .loginBtn--google:before { 192 | border-right: #BB3F30 1px solid; 193 | background: url('imgs/icon_google.png') 6px 6px no-repeat; 194 | } 195 | .loginBtn--google:hover, 196 | .loginBtn--google:focus { 197 | background: #E74B37; 198 | } 199 | 200 | /* Filter Panel --------------------------------*/ 201 | .filterPanel { 202 | text-align: center; 203 | height: 100%; 204 | width: 0; 205 | position: fixed; 206 | z-index: 1; 207 | top: 0; 208 | left: 0; 209 | background-color: #1a1a1f; 210 | color: #000; 211 | overflow-x: hidden; 212 | padding-top: 100px; 213 | transition: 0.5s; 214 | } 215 | 216 | .filterPanel .control-group{ 217 | margin: 20px; 218 | } 219 | 220 | .filterPanel a { 221 | padding: 8px 8px 8px 32px; 222 | text-decoration: none; 223 | font-size: 25px; 224 | display: block; 225 | transition: 0.3s; 226 | } 227 | 228 | .filterPanel .controls input{ 229 | color: #000000; 230 | text-align: center; 231 | } 232 | 233 | .filterPanel a:hover { 234 | color: #f1f1f1; 235 | } 236 | 237 | .filterPanel .closebtn { 238 | position: absolute; 239 | top: 0; 240 | right: 25px; 241 | font-size: 36px; 242 | margin-left: 50px; 243 | color: #1a1a1ffff; 244 | } 245 | 246 | .expense-overview tr { 247 | background-color: #1a1a1f; 248 | border-color: #ffffff; 249 | } 250 | 251 | .expense-overview td, 252 | .expense-overview th{ 253 | padding: 5px; 254 | border-radius: 10px; 255 | text-align: center; 256 | } 257 | 258 | .add-expense-form { 259 | border-radius: 15px; 260 | padding: 48px 24px 24px 24px; 261 | } 262 | 263 | .add-expense-form .row { 264 | margin-bottom: 12px; 265 | } 266 | 267 | .add-expense-form .row .col{ 268 | margin: 0px; 269 | padding: 0px 10px; 270 | } 271 | -------------------------------------------------------------------------------- /tracker/static/main.js: -------------------------------------------------------------------------------- 1 | var ExpenseTracker = {}; 2 | 3 | $(function() { 4 | if ($('.expense-overview').length) ExpenseTracker.Filter.init(); 5 | }); 6 | 7 | ExpenseTracker.Filter = { 8 | init: function() { 9 | var self = this; 10 | $('.open-filter-btn').click(function(e) { 11 | e.preventDefault(); 12 | self.openFilterPanel(); 13 | }); 14 | 15 | $('.closebtn').click(function(e) { 16 | e.preventDefault(); 17 | self.closeFilterPanel(); 18 | }); 19 | }, 20 | openFilterPanel: function() { 21 | document.getElementById("filterPanel").style.width = "100%"; 22 | }, 23 | closeFilterPanel: function() { 24 | document.getElementById("filterPanel").style.width = "0"; 25 | } 26 | } -------------------------------------------------------------------------------- /tracker/tables.py: -------------------------------------------------------------------------------- 1 | import django_tables2 as tables 2 | from django.utils.safestring import mark_safe 3 | from django.utils.html import escape 4 | from .models import Expense 5 | 6 | 7 | class ActionButtons(tables.Column): 8 | empty_values = list() 9 | def render(self, value, record): 10 | html = "" % escape(record.id) 11 | return mark_safe(html) 12 | 13 | 14 | class ExpenseTable(tables.Table): 15 | actions = ActionButtons() 16 | class Meta: 17 | model = Expense 18 | fields = ( 19 | 'date', 20 | 'description', 21 | 'type', 22 | 'payment', 23 | 'amount' 24 | ) 25 | attrs = {"class": "table table-striped dt-responsive nowrap"} 26 | empty_text = "No records found" 27 | # template_name = 'django_tables2/bootstrap-responsive.html' 28 | -------------------------------------------------------------------------------- /tracker/templates/analytics/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'tracker/base.html'%} 2 | 3 | {% load month_labels %} 4 | {% block body %} 5 | 6 |
7 | {% if menu_labels %} 8 |
9 | 21 |
22 | {% endif %} 23 | 24 | {% if context_type == 'annually' or context_type == 'monthly' or context_type == 'daily' %} 25 |
26 | 27 | {% if submenu %} 28 | 38 | {% endif %} 39 |
40 | {% if datasets %} 41 | 42 | {% load django_tables2 %} 43 | {%if expense_table_in_month_view%} 44 | {% render_table expense_table_in_month_view%} 45 | {%endif%} 46 | {%if expense_table_in_daily_view %} 47 | {% render_table expense_table_in_daily_view%} 48 | {%endif%} 49 | 94 | {% else %} 95 | No data 96 | {% endif %} 97 |
98 |
99 | {% endif %} 100 |
101 | 102 | 103 | 104 | {% endblock %} 105 | 106 | 107 | -------------------------------------------------------------------------------- /tracker/templates/landing/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'tracker/base.html'%} 2 | 3 | {% block body %} 4 |
5 |
6 |

Squirrel

7 |

An Open Source Expense Tracker and Analytics Platform

8 |
9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /tracker/templates/tracker/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Squirrel 6 | {% load staticfiles %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 64 | 65 | {% block body %} 66 | {% endblock %} 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /tracker/templates/tracker/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'tracker/base.html'%} 2 | 3 | {% block body %} 4 |
5 | {% if context_type == 'dashboard'%} 6 |
7 |
8 |
9 |
10 |
11 |

12 | Total Records

13 |

{{total_records}}

14 |

15 | {% if total_expenses_diff > 0 %} 16 | keyboard_arrow_up {{total_records_diff | stringformat:"+d" | slice:"1:"}}% 17 | {% endif %} 18 | {% if total_expenses_diff < 0 %} 19 | keyboard_arrow_down {{total_records_diff | stringformat:"+d" | slice:"1:"}}% 20 | {% endif %} 21 | from last month 22 |

23 |
24 |
25 |
This month:
26 |
{{totalRecordsInThisMonth}}
27 |
Last month:
28 |
{{totalRecordsInLastMonth}}
29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 |

37 | Total Expenses

38 |

${{total_expenses}}

39 |

40 | {% if total_expenses_diff > 0 %} 41 | keyboard_arrow_up {{total_expenses_diff | stringformat:"+d" | slice:"1:"}}% 42 | {% endif %} 43 | {% if total_expenses_diff < 0 %} 44 | keyboard_arrow_down {{total_expenses_diff | stringformat:"+d" | slice:"1:"}}% 45 | {% endif %} 46 | from last month 47 |

48 |
49 |
50 |
This month:
51 |
${{totalExpensesInThisMonth}}
52 |
Last month:
53 |
${{totalExpensesInLastMonth}}
54 |
55 |
56 |
57 | 58 |
59 |
60 |
61 |

62 | Total Categories

63 |

{{categories_cnt}}

64 |

65 | {% if total_expenses_diff > 0 %} 66 | keyboard_arrow_up {{total_categories_diff | stringformat:"+d" | slice:"1:"}}% 67 | {% endif %} 68 | {% if total_expenses_diff < 0 %} 69 | keyboard_arrow_down {{total_categories_diff | stringformat:"+d" | slice:"1:"}}% 70 | {% endif %} 71 | from last month 72 |

73 |
74 |
75 |
This month:
76 |
{{categories_in_this_month}}
77 |
Last month:
78 |
{{categories_in_last_month}}
79 |
80 |
81 |
82 | 83 |
84 |
85 |
86 |

87 | Avg. year

88 |

${{avg_year}}

89 |

90 | dashboard 91 | 92 |

93 |
94 |
95 |
Avg. month:
96 |
${{avg_month}}
97 |
Avg. day:
98 |
${{avg_day}}
99 |
100 |
101 |
102 |
103 |
104 | 105 | {% if categories %} 106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 | 114 | 144 |
145 | 146 |
147 |
148 |
149 |
150 | 151 | 152 |
153 |
154 |
155 |
156 |

Expenses per Categories

157 | 158 | 217 |
218 |
219 |
220 |
221 |
222 |
223 | {% endif %} 224 | 225 | 226 | {% if latest30DaysData %} 227 |
228 |
229 |
230 |
231 |
232 | 233 | 279 |
280 |
281 |
282 |
283 |
284 | {% endif %} 285 | 286 |
287 |
288 |
289 | 298 |
299 |
300 | 309 |
310 |
311 | 320 |
321 |
322 |
323 | {% endif %} 324 |
325 | 326 | {% endblock %} 327 | -------------------------------------------------------------------------------- /tracker/templates/tracker/expense_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'tracker/base.html'%} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |
8 |
9 |
10 | {% csrf_token %} 11 | {% include 'tracker/form_template.html' %} 12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /tracker/templates/tracker/form_template.html: -------------------------------------------------------------------------------- 1 | {% for field in form %} 2 |
3 | 4 | {{ field.errors }} 5 | 6 |
7 | {{ field }} 8 | 9 |
10 |
11 | 12 | {% endfor %} -------------------------------------------------------------------------------- /tracker/templates/tracker/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'tracker/base.html'%} 2 | {% load django_tables2 crispy_forms_tags %} 3 | 4 | {% block body %} 5 |
6 |
7 |
8 | 11 |
12 |
13 |
14 | 15 |
16 |
17 | 18 |
19 | 20 |
21 |
22 | {% render_table table %} 23 |
24 |
25 | 26 | 27 |
28 | × 29 |
30 | {% crispy filter.form filter.form.helper %} 31 |
32 |
33 | 34 | {% endblock %} 35 |
-------------------------------------------------------------------------------- /tracker/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wingkwong/squirrel/0841d68f744b22ed2ba85917257429359a498f9a/tracker/templatetags/__init__.py -------------------------------------------------------------------------------- /tracker/templatetags/month_labels.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | @register.filter 6 | def get_by_index(lst, idx): 7 | return lst[idx-1] -------------------------------------------------------------------------------- /tracker/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /tracker/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.contrib.auth.decorators import login_required 3 | from . import views 4 | 5 | app_name = 'tracker' 6 | 7 | urlpatterns = [ 8 | # / 9 | path('', views.LandingView.landing, name ="landing"), 10 | 11 | # /dashboard 12 | path('dashboard', login_required(views.Dashboard.index), name="dashboard"), 13 | 14 | # /tracker 15 | path('tracker', login_required(views.IndexView.as_view()), name ="expense"), 16 | 17 | # /add 18 | path('tracker/add', login_required(views.ExpenseCreate.as_view()), name="create_expense"), 19 | 20 | # /update/{id} 21 | path('tracker/update/', login_required(views.ExpenseUpdate.as_view()), name="update_expense"), 22 | 23 | # /delete/{id} 24 | path('tracker/delete/', login_required(views.ExpenseDelete.as_view()), name="delete_expense"), 25 | 26 | # /export 27 | path('tracker/export', login_required(views.ReportView.export), name="export"), 28 | 29 | # /analytics 30 | # FIXME: redirect to dashboard 31 | path('analytics', login_required(views.Dashboard.index), name="analytics"), 32 | 33 | # /analytics/2018 34 | path('analytics/', login_required(views.AnalyticsView.annually), name="annually"), 35 | 36 | # /analytics/2018/02 37 | path('analytics//', login_required(views.AnalyticsView.monthly), name="monthly"), 38 | 39 | # /analytics/2018/02/20 40 | path('analytics///', login_required(views.AnalyticsView.daily), name="daily"), 41 | ] 42 | -------------------------------------------------------------------------------- /tracker/utils.py: -------------------------------------------------------------------------------- 1 | from django_tables2 import SingleTableView 2 | from django_tables2.config import RequestConfig 3 | import math 4 | 5 | class PagedFilteredTableView(SingleTableView): 6 | filter_class = None 7 | formhelper_class = None 8 | context_filter_name = 'filter' 9 | 10 | def get_queryset(self, **kwargs): 11 | qs = super(PagedFilteredTableView, self).get_queryset() 12 | self.filter = self.filter_class(self.request.GET, queryset=qs) 13 | self.filter.form.helper = self.formhelper_class() 14 | return self.filter.qs 15 | 16 | def get_table(self, **kwargs): 17 | table = super(PagedFilteredTableView, self).get_table() 18 | RequestConfig(self.request, paginate={'page': self.kwargs['page'], 19 | "per_page": self.paginate_by}).configure(table) 20 | return table 21 | 22 | def get_context_data(self, **kwargs): 23 | context = super(PagedFilteredTableView, self).get_context_data() 24 | context[self.context_filter_name] = self.filter 25 | return context 26 | 27 | class AmountUnitUtil(): 28 | millnames = ['','K','M','B','T'] 29 | 30 | def convertToMills(n): 31 | n = float(n) 32 | index = max(0, min(len(AmountUnitUtil.millnames) - 1, int(math.floor(0 if n == 0 else math.log10(abs(n))/3)))) 33 | return '{:.2f}{}'.format(n / 10**(3 * index), AmountUnitUtil.millnames[index]) -------------------------------------------------------------------------------- /tracker/views.py: -------------------------------------------------------------------------------- 1 | from django.views import generic 2 | from django.views.generic.edit import CreateView, UpdateView, DeleteView 3 | from django.urls import reverse_lazy 4 | from django_tables2 import RequestConfig 5 | from django.http import HttpResponseRedirect 6 | from django.shortcuts import render 7 | from django.db.models import Sum, Count 8 | from .tables import ExpenseTable 9 | from .models import Expense 10 | from .forms import ExpenseTableHelper 11 | from .filters import ExpenseFilter 12 | from .utils import AmountUnitUtil 13 | from django.http import HttpResponse 14 | from calendar import monthrange 15 | from .forms import AddExpenseForm 16 | import csv 17 | import random 18 | import datetime 19 | 20 | 21 | class Dashboard(): 22 | template_name = "tracker/dashboard.html" 23 | 24 | def index(request): 25 | # init 26 | avg_year = 0 27 | avg_month = 0 28 | avg_day = 0 29 | 30 | now = datetime.datetime.now() 31 | 32 | # get user expense objects 33 | exp = Expense.objects.filter(created_by=request.user) 34 | 35 | # count total record 36 | total_records = exp.count() 37 | 38 | # count total record in this month 39 | current_month = now.month 40 | total_records_in_this_month = exp.filter(created_at__month=current_month).count() 41 | if total_records_in_this_month is None: 42 | total_records_in_this_month = 0 43 | 44 | # count total record in last month 45 | last_month = now.month - 1 46 | if last_month<0: 47 | last_month = 11 48 | total_records_in_last_month = exp.filter(created_at__month=last_month).count() 49 | if total_records_in_last_month is None: 50 | total_records_in_last_month = 0 51 | 52 | print(total_records_in_this_month) 53 | print(total_records_in_last_month) 54 | # diff between this month and last month 55 | if total_records == 0: 56 | total_records_diff = 0 57 | else: 58 | total_records_diff = ( (total_records_in_this_month - total_records_in_last_month) / total_records ) * 100 59 | 60 | # sum up all the expenses 61 | total_expenses = exp.aggregate(amount=Sum('amount'))['amount'] 62 | if total_expenses is None: 63 | total_expenses = 0 64 | 65 | # sum up all the expenses in this month 66 | total_expenses_in_this_month = exp.filter(created_at__month=current_month).aggregate(amount=Sum('amount'))['amount'] 67 | if total_expenses_in_this_month is None: 68 | total_expenses_in_this_month = 0 69 | 70 | # sum up all the expenses in last month 71 | total_expenses_in_last_month = exp.filter(created_at__month=last_month).aggregate(amount=Sum('amount'))['amount'] 72 | if total_expenses_in_last_month is None: 73 | total_expenses_in_last_month = 0 74 | 75 | # diff between this month and last month 76 | if total_expenses == 0: 77 | total_expenses_diff = 0 78 | else: 79 | total_expenses_diff = ( (total_expenses_in_this_month - total_expenses_in_last_month) / total_expenses ) * 100 80 | 81 | # get all categories 82 | categories = list(exp.values('type').distinct().order_by('type').values_list('type', flat=True)) 83 | 84 | # get categories record count 85 | categories_record_cnt = list(exp.values('type').annotate(the_count=Count('type')).order_by('type').values_list('the_count', flat=True)) 86 | 87 | # categories count 88 | categories_cnt = exp.values('type').annotate(the_count=Count('type')).count() 89 | if categories_cnt is None: 90 | categories_cnt = 0 91 | 92 | # total expense per category 93 | categories_expense = list(exp.values('type').order_by('type').annotate(the_count=Sum('amount')).values_list('the_count', flat=True)) 94 | 95 | categories_color_arr = [] 96 | for cat in categories: 97 | # generate random color 98 | r = lambda: random.randint(0, 255) 99 | color = '#%02X%02X%02X' % (r(), r(), r()) 100 | categories_color_arr.append(color) 101 | 102 | # categories count in this month 103 | categories_in_this_month = exp.filter(created_at__month=current_month).values('type').annotate(the_count=Count('type')).count() 104 | if categories_in_this_month is None: 105 | categories_in_this_month = 0 106 | 107 | # categories count in last month 108 | categories_in_last_month = exp.filter(created_at__month=last_month).values('type').annotate(the_count=Count('type')).count() 109 | if categories_in_last_month is None: 110 | categories_in_last_month = 0 111 | 112 | # diff between this month and last month 113 | if categories_cnt == 0: 114 | total_categories_diff = 0 115 | else: 116 | total_categories_diff = ((categories_in_this_month - categories_in_last_month) / categories_cnt) * 100 117 | 118 | # list out dates for the following processing. 119 | dates = list(exp.values('date') 120 | .values_list('date', flat=True)) 121 | 122 | # avg amount per year, per month and per day 123 | year_arr = [] 124 | month_arr = [] 125 | day_arr = [] 126 | 127 | for date in dates: 128 | if date.year not in year_arr: 129 | year_arr.append(date.year) 130 | if date.year not in month_arr: 131 | month_arr.append(date.month) 132 | if date.year not in day_arr: 133 | day_arr.append(date.day) 134 | 135 | if total_expenses > 0: 136 | avg_year = AmountUnitUtil.convertToMills(total_expenses / year_arr.__len__()) 137 | avg_month = AmountUnitUtil.convertToMills(total_expenses / month_arr.__len__()) 138 | avg_day = AmountUnitUtil.convertToMills(total_expenses / day_arr.__len__()) 139 | total_expenses = AmountUnitUtil.convertToMills(total_expenses) 140 | 141 | # Get Latest 30 days records 142 | 143 | latest30DaysArr = list(exp.filter(created_at__lte=datetime.datetime.today(), 144 | created_at__gt=datetime.datetime.today() - datetime.timedelta(days=30)) 145 | .values('date').distinct().order_by('date').values_list('date', flat=True)) 146 | 147 | latest30DaysLabel = [] 148 | for day in latest30DaysArr: 149 | latest30DaysLabel.append(day.strftime('%d/%m')) 150 | 151 | 152 | latest30DaysData = list(exp.filter(created_at__lte=datetime.datetime.today(), created_at__gt=datetime.datetime.today()-datetime.timedelta(days=30)) 153 | .values('date').distinct().order_by('date') 154 | .annotate(amount=Sum('amount')).values_list('amount', flat=True)) 155 | 156 | context = { 157 | 'context_type': 'dashboard', 158 | 'total_records': total_records, 159 | 'total_expenses': total_expenses, 160 | 'categories': categories, 161 | 'categories_record_cnt': categories_record_cnt, 162 | 'categories_cnt': categories_cnt, 163 | 'categories_expense': categories_expense, 164 | 'categories_color_arr': categories_color_arr, 165 | 'avg_year': avg_year, 166 | 'avg_month': avg_month, 167 | 'avg_day':avg_day, 168 | 'current_year': now.year, 169 | 'current_month': now.month, 170 | 'current_day': now.day, 171 | 'latest30DaysLabel': latest30DaysLabel, 172 | 'latest30DaysData': latest30DaysData, 173 | 'totalRecordsInThisMonth': total_records_in_this_month, 174 | 'totalRecordsInLastMonth': total_records_in_last_month, 175 | 'totalExpensesInThisMonth': float("{0:.2f}".format(total_expenses_in_this_month)), 176 | 'totalExpensesInLastMonth': float("{0:.2f}".format(total_expenses_in_last_month)), 177 | 'categories_in_this_month': categories_in_this_month, 178 | 'categories_in_last_month': categories_in_last_month, 179 | 'total_expenses_diff': float("{0:.2f}".format(total_expenses_diff)), 180 | 'total_records_diff': float("{0:.2f}".format(total_records_diff)), 181 | 'total_categories_diff': float("{0:.2f}".format(total_categories_diff)) 182 | 183 | } 184 | 185 | return render(request, "tracker/dashboard.html", context) 186 | 187 | 188 | class IndexView(generic.ListView): 189 | template_name = "tracker/index.html" 190 | context_object_name = "records" 191 | model = Expense 192 | table_class = ExpenseTable 193 | filter_class = ExpenseFilter 194 | formhelper_class = ExpenseTableHelper 195 | 196 | def get_queryset(self): 197 | return Expense.objects.filter(created_by=self.request.user) 198 | 199 | def get_context_data(self, **kwargs): 200 | context = super(IndexView, self).get_context_data(**kwargs) 201 | filter = ExpenseFilter(self.request.GET, queryset=self.get_queryset(**kwargs)) 202 | filter.form.helper = ExpenseTableHelper() 203 | table = ExpenseTable(filter.qs) 204 | table.order_by = '-date' 205 | RequestConfig(self.request, paginate={'per_page': 10}).configure(table) 206 | context['filter'] = filter 207 | context['table'] = table 208 | return context 209 | 210 | 211 | class ExpenseCreate(CreateView): 212 | model = Expense 213 | form_class = AddExpenseForm 214 | 215 | def form_valid(self, form): 216 | obj = form.save(commit=False) 217 | obj.created_by = self.request.user 218 | obj.created_at = datetime.datetime.now() 219 | obj.save() 220 | return HttpResponseRedirect(reverse_lazy('tracker:expense')) 221 | 222 | 223 | class ExpenseUpdate(UpdateView): 224 | model = Expense 225 | fields = [ 226 | 'date', 227 | 'description', 228 | 'type', 229 | 'payment', 230 | 'amount' 231 | ] 232 | 233 | 234 | # obsolete 235 | class ExpenseDelete(DeleteView): 236 | model = Expense 237 | success_url = reverse_lazy('tracker:expense') 238 | 239 | 240 | class AnalyticsView(generic.ListView): 241 | template_name = "analytics/index.html" 242 | context_object_name = "records" 243 | model = Expense 244 | 245 | def annually(request, year): 246 | # get user expense objects 247 | exp = Expense.objects.filter(created_by=request.user) 248 | 249 | # retrieve distinct types 250 | expense_type = list(exp.filter(date__year=year).values('type').distinct().order_by() 251 | .values_list('type', flat=True)) 252 | 253 | datasets = [] 254 | months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", 255 | "November", "December"] 256 | 257 | for type in expense_type: 258 | # expense 259 | arr = list(exp.filter(date__year=year).filter(type=type).distinct().order_by('date')) 260 | 261 | # expense month by type 262 | month_tmp_arr = list(exp.filter(date__year=year).filter(type=type).values('date').distinct() 263 | .order_by('date') 264 | .values_list('date', flat=True)) 265 | 266 | # expense amount by type 267 | expense_tmp_arr = list(exp.filter(date__year=year).filter(type=type) 268 | .values('date').distinct().order_by('date') 269 | .annotate(amount=Sum('amount')).values_list('amount', flat=True)) 270 | 271 | # init array with size 12 with value 0 272 | monthly_expense_cnt = [0] * 12 273 | 274 | for i, m in enumerate(arr): 275 | monthly_expense_cnt[m.date.month-1] += m.amount 276 | 277 | total_amount = [] 278 | 279 | for j, k in enumerate(monthly_expense_cnt): 280 | o = {} 281 | o['x'] = months[j]; 282 | o['y'] = monthly_expense_cnt[j]; 283 | total_amount.append(o) 284 | 285 | # generate random color 286 | r = lambda: random.randint(0, 255) 287 | color = '#%02X%02X%02X' % (r(), r(), r()) 288 | 289 | # construct dataset 290 | dataset = { 291 | 'label': type, 292 | 'backgroundColor': color, 293 | 'borderColor': color, 294 | 'data': total_amount, 295 | 'fill': 'false' 296 | } 297 | 298 | datasets.append(dataset) 299 | 300 | # fetch available years for menu labels 301 | dates = list(exp.values('date') 302 | .values_list('date', flat=True)) 303 | year_arr = [] 304 | for date in dates: 305 | if date.year not in year_arr: 306 | year_arr.append(date.year) 307 | year_arr.sort() 308 | 309 | # construct submenu 310 | expense_per_month = [] 311 | month_not_none = [] 312 | for i, month in enumerate(months): 313 | amount = exp.filter(date__year=year).filter(date__month=(i+1)).values('date').distinct().order_by('date').aggregate(amount=Sum('amount'))['amount'] 314 | if amount is not None: 315 | expense_per_month.append(float("{0:.2f}".format(amount))) 316 | month_not_none.append((months[i])) 317 | 318 | 319 | if expense_per_month: 320 | submenu = zip(month_not_none, expense_per_month) 321 | else: 322 | submenu = False 323 | 324 | context = { 325 | 'context_type': 'annually', 326 | 'datasets': datasets, 327 | 'labels': months, 328 | 'title': 'Annual Report in ' + str(year), 329 | 'report_type': 'line', 330 | 'menu_labels': year_arr, 331 | 'x_axis_label': 'Month', 332 | 'submenu': submenu 333 | } 334 | 335 | return render(request, "analytics/index.html", context) 336 | 337 | def monthly(request, year, month): 338 | # get user expense objects 339 | exp = Expense.objects.filter(created_by=request.user) 340 | 341 | expense_table_in_month_view = ExpenseTable(exp.filter(date__year=year).filter(date__month=month)) 342 | expense_table_in_month_view.paginate(page=request.GET.get('page', 1), per_page=10) 343 | 344 | months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", 345 | "November", "December"] 346 | months_reverse = dict(January=1, February=2, March=3, April=4) 347 | datasets = [] 348 | 349 | # retrieve distinct types 350 | expense_type = list(exp.filter(date__year=year).filter(date__month=month).values('type').distinct().order_by() 351 | .values_list('type', flat=True)) 352 | 353 | lastday = monthrange(year, month)[1] 354 | 355 | days = list(range(1,lastday+1)) 356 | 357 | for type in expense_type: 358 | # expense 359 | expense_queryset = exp.filter(date__year=year).filter(date__month=month).filter(type=type).distinct().order_by('date') 360 | arr = list(expense_queryset) 361 | 362 | # expense month by type 363 | month_tmp_arr = list(exp.filter(date__year=year).filter(date__month=month).filter(type=type).values('date').distinct() 364 | .order_by('date') 365 | .values_list('date', flat=True)) 366 | 367 | # expense amount by type 368 | expense_tmp_arr = list(exp.filter(date__year=year).filter(date__month=month).filter(type=type) 369 | .values('date').distinct().order_by('date') 370 | .annotate(amount=Sum('amount')).values_list('amount', flat=True)) 371 | 372 | # init array 373 | daily_expense_cnt = [0] * lastday 374 | for i, m in enumerate(arr): 375 | daily_expense_cnt[m.date.day - 1] += m.amount 376 | 377 | total_amount = [] 378 | for j, k in enumerate(daily_expense_cnt[1:]): 379 | o = {} 380 | o['x'] = days[j]; 381 | o['y'] = daily_expense_cnt[j]; 382 | total_amount.append(o) 383 | 384 | # generate random color 385 | r = lambda: random.randint(0, 255) 386 | color = '#%02X%02X%02X' % (r(), r(), r()) 387 | 388 | # construct dataset 389 | dataset = { 390 | 'label': type, 391 | 'backgroundColor': color, 392 | 'borderColor': color, 393 | 'data': total_amount, 394 | 'fill': 'false' 395 | } 396 | 397 | datasets.append(dataset) 398 | 399 | # fetch available months for menu labels 400 | dates = list(exp.values('date') 401 | .values_list('date', flat=True)) 402 | month_arr = [] 403 | month_labels = [] 404 | for date in dates: 405 | if date.month not in month_arr: 406 | month_arr.append(date.month) 407 | month_arr.sort() 408 | 409 | for single_month in month_arr: 410 | month_labels.append(months[single_month - 1]) 411 | # construct submenu 412 | expense_per_day = [] 413 | day_not_none = [] 414 | for i, d in enumerate(days): 415 | amount = exp.filter(date__year=year).filter(date__month=month).filter(date__day=(i+1)).values('date').distinct().order_by( 416 | 'date').aggregate(amount=Sum('amount'))['amount'] 417 | if amount is not None: 418 | expense_per_day.append(float("{0:.2f}".format(amount))) 419 | day_not_none.append((i+1)) 420 | 421 | if expense_per_day: 422 | submenu = zip(day_not_none, expense_per_day) 423 | else: 424 | submenu = False 425 | 426 | context = { 427 | 'context_type': 'monthly', 428 | 'datasets': datasets, 429 | 'monthly_expense': expense_type, 430 | 'labels': days, 431 | 'title': 'Monthly Report on ' + str(months[month-1]) + ' ' + str(year), 432 | 'report_type': 'bar', 433 | 'selected_year':year , 434 | 'menu_labels': month_arr, 435 | 'month_labels': month_labels, 436 | 'x_axis_label': 'Day', 437 | 'submenu': submenu, 438 | 'months': months, 439 | 'expense_table_in_month_view': expense_table_in_month_view 440 | } 441 | return render(request, "analytics/index.html", context) 442 | 443 | def daily(request, year, month, day): 444 | # get user expense objects 445 | exp = Expense.objects.filter(created_by=request.user) 446 | 447 | # expense table 448 | expense_table_in_daily_view = ExpenseTable(exp.filter(date__year=year).filter(date__month=month).filter(date__day=day)) 449 | expense_table_in_daily_view.paginate(page=request.GET.get('page', 1), per_page=10) 450 | 451 | type =[] 452 | # retrieve distinct types 453 | expense_type = list( 454 | exp.filter(date__year=year).filter(date__month=month) 455 | .filter(date__day=day).values('type').distinct().order_by() 456 | .values_list('type', flat=True)) 457 | 458 | datasets = [] 459 | expense_arr = [] 460 | color_arr = [] 461 | for type in expense_type: 462 | # expense amount by type 463 | expense_tmp_arr = list(exp.filter(date__year=year).filter(date__month=month).filter(date__day=day) 464 | .filter(type=type) 465 | .values('date').distinct().order_by('date') 466 | .annotate(amount=Sum('amount')).values_list('amount', flat=True)) 467 | # get the sum 468 | expense_arr.append(expense_tmp_arr[0]) 469 | 470 | # generate random color 471 | r = lambda: random.randint(0, 255) 472 | color = '#%02X%02X%02X' % (r(), r(), r()) 473 | color_arr.append(color) 474 | 475 | # construct dataset 476 | if color_arr and expense_arr: 477 | dataset = { 478 | 'label': type, 479 | 'backgroundColor': color_arr, 480 | 'borderColor': color_arr, 481 | 'data': expense_arr 482 | } 483 | datasets.append(dataset) 484 | 485 | # construct submenu 486 | expense_per_type = [] 487 | for i, type in enumerate(expense_type): 488 | amount = exp.filter(date__year=year).filter(date__month=month).filter(date__day=day).filter(type=type).values('date').distinct().order_by('date').aggregate(amount=Sum('amount'))['amount'] 489 | if amount is not None: 490 | expense_per_type.append(float("{0:.2f}".format(amount))) 491 | 492 | if expense_per_type: 493 | submenu = zip(expense_type, expense_per_type) 494 | else: 495 | submenu = False 496 | 497 | context = { 498 | 'context_type': 'daily', 499 | 'datasets': datasets, 500 | 'daily_expense': expense_type, 501 | 'labels': expense_type, 502 | 'title': 'Daily Report on ' + str(day) + '/' + str(month) + '/' + str(year) , 503 | 'x_axis_label': 'Type', 504 | 'report_type': 'pie', 505 | 'submenu': submenu, 506 | 'expense_table_in_daily_view': expense_table_in_daily_view 507 | } 508 | return render(request, "analytics/index.html", context) 509 | 510 | 511 | class LandingView(): 512 | def landing(request): 513 | context = {} 514 | return render(request, "landing/index.html", context) 515 | 516 | 517 | class ReportView(): 518 | def export(request): 519 | exp = Expense.objects.filter(created_by=request.user).order_by('id') 520 | opts = exp.model._meta 521 | response = HttpResponse(content_type='text/csv') 522 | response['Content-Disposition'] = 'attachment;filename=export.csv' 523 | writer = csv.writer(response) 524 | field_names = [field.name for field in opts.fields] 525 | # writer.writerow(field_names) 526 | for obj in exp: 527 | writer.writerow([getattr(obj, field) for field in field_names]) 528 | return response 529 | 530 | -------------------------------------------------------------------------------- /website/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wingkwong/squirrel/0841d68f744b22ed2ba85917257429359a498f9a/website/__init__.py -------------------------------------------------------------------------------- /website/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for website project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.0.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '(pwnf+znz^!914bc%wg8)wj+c!_7y8-71ya65k(79gpiw8ye+2' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'tracker.apps.TrackerConfig', 41 | 'accounts.apps.AccountsConfig', 42 | 'django_tables2', 43 | 'django_filters', 44 | 'crispy_forms', 45 | 'tracker.templatetags.month_labels', 46 | 'social_django' 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | 'django.middleware.security.SecurityMiddleware', 51 | 'django.contrib.sessions.middleware.SessionMiddleware', 52 | 'django.middleware.common.CommonMiddleware', 53 | 'django.middleware.csrf.CsrfViewMiddleware', 54 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 55 | 'django.contrib.messages.middleware.MessageMiddleware', 56 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 57 | ] 58 | 59 | ROOT_URLCONF = 'website.urls' 60 | 61 | TEMPLATES = [ 62 | { 63 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 64 | 'DIRS': ['./templates',], 65 | 'APP_DIRS': True, 66 | 'OPTIONS': { 67 | 'context_processors': [ 68 | 'django.template.context_processors.debug', 69 | 'django.template.context_processors.request', 70 | 'django.contrib.auth.context_processors.auth', 71 | 'django.contrib.messages.context_processors.messages', 72 | 'social_django.context_processors.backends', 73 | 'social_django.context_processors.login_redirect', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | WSGI_APPLICATION = 'website.wsgi.application' 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 84 | 85 | DATABASES = { 86 | 'default': { 87 | 'ENGINE': 'django.db.backends.sqlite3', 88 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 89 | } 90 | } 91 | 92 | 93 | # Password validation 94 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 95 | 96 | AUTH_PASSWORD_VALIDATORS = [ 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 102 | }, 103 | { 104 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 105 | }, 106 | { 107 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 108 | }, 109 | ] 110 | 111 | 112 | # Internationalization 113 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 114 | 115 | LANGUAGE_CODE = 'en-us' 116 | 117 | TIME_ZONE = 'UTC' 118 | 119 | USE_I18N = True 120 | 121 | USE_L10N = True 122 | 123 | USE_TZ = True 124 | 125 | 126 | # Static files (CSS, JavaScript, Images) 127 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 128 | 129 | STATIC_URL = '/static/' 130 | 131 | LOGIN_URL = '/accounts/login' 132 | LOGIN_REDIRECT_URL = '/tracker' 133 | 134 | AUTHENTICATION_BACKENDS = ( 135 | 'social_core.backends.open_id.OpenIdAuth', # for Google authentication 136 | 'social_core.backends.google.GoogleOpenId', # for Google authentication 137 | 'social_core.backends.google.GoogleOAuth2', # for Google authentication 138 | 'django.contrib.auth.backends.ModelBackend', 139 | ) 140 | 141 | SOCIAL_AUTH_PIPELINE = ( 142 | 'social_core.pipeline.social_auth.social_details', 143 | 'social_core.pipeline.social_auth.social_uid', 144 | 'social_core.pipeline.social_auth.auth_allowed', 145 | 'social_core.pipeline.social_auth.social_user', 146 | 'social_core.pipeline.social_auth.associate_user', 147 | 'social_core.pipeline.social_auth.load_extra_data', 148 | 'social_core.pipeline.user.user_details', 149 | 'accounts.pipelines.saveAvatar', 150 | ) 151 | 152 | SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=''#ClientKey 153 | SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=''#SecretKey 154 | -------------------------------------------------------------------------------- /website/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | path('', include('tracker.urls')), 7 | path('', include('accounts.urls')) 8 | ] 9 | 10 | urlpatterns += [ 11 | path('accounts/', include('django.contrib.auth.urls')), 12 | path('auth/', include('social_django.urls', namespace='social')), 13 | ] 14 | 15 | -------------------------------------------------------------------------------- /website/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for website project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website.settings") 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------