Python Forum
Thread Rating:
  • 0 Vote(s) - 0 Average
  • 1
  • 2
  • 3
  • 4
  • 5
My First App
#1
Hello Everyone,

I have created a habit tracking app that I would love to get some feedback on. This is my first real OOP project in Python. If anyone would be willing to review my app and give me some feedback, I would be extremely grateful. Thanks in advance!

Github URL: https://github.com/Bcopeland64/IU-Habit-Tracker-App
Apologies for not posting the code here. Please find the app in question below:

main.py file:

import click
from habit import HabitTracker, Habit
import questionary
from analytics import *
from db2 import HabitDB, HabitTracker

tracker = HabitTracker()



@click.group()
def cli():
    pass

@cli.command()
def create():
    name = questionary.text("Enter the habit name: ").ask()
    period = questionary.select("Enter the habit period:", choices=["daily", "weekly", "monthly"]).ask()
    tracker.create_habit(name, period)
    click.echo(f'Habit "{name}" with period "{period}" created successfully!')

@cli.command()
def delete():
    name = questionary.text("Enter the habit name: ").ask()
    tracker.delete_habit(name)
    click.echo(f'Habit "{name}" deleted successfully!')

@cli.command()
def list_habit_groups():
    habits = tracker.get_habits()
    click.echo('Current habits:')
    for habit in habits:
        click.echo(f'- {habit.name} ({habit.period})')

@cli.command()
def list_habit_groups_period():
    period = questionary.select("Enter the habit period:", choices=["daily", "weekly", "monthly"]).ask()
    habits = tracker.get_habits_by_period(period)
    click.echo(f'Current {period} habits:')
    for habit in habits:
        click.echo(f'- {habit.name}')

@cli.command()
def longest_streak():
    longest_streak_habit = tracker.get_longest_streak()
    if longest_streak_habit:
        click.echo(f'Habit with longest streak: {longest_streak_habit.name} ({longest_streak_habit.get_streak()})')
    else:
        click.echo('No habits with a streak.')

@cli.command()
def longest_streak_habit():
    name = questionary.text("Enter the habit name: ").ask()
    streak = tracker.get_longest_streak_by_habit(name)
    if streak:
        click.echo(f'Longest streak for habit "{name}": {streak}')
    else:
        click.echo(f'Habit "{name}" not found.')

@cli.command()
def mark():
    name = questionary.text("Enter the habit name: ").ask()
    tracker.mark_complete(name)
    click.echo(f'Habit "{name}" marked successfully!')

@cli.command()
def unmark():
    name = questionary.text("Enter the habit name: ").ask()
    tracker.mark_incomplete(name)
    click.echo(f'Habit "{name}" unmarked successfully!')

def main():
    while True:
        command = input('Enter a command (create, delete, list, list-period, longest-streak, longest-streak-habit, or exit): ')
        if command == 'create':
            name = input('Enter the habit name: ')
            period = input('Enter the habit period (daily or weekly): ')
            tracker.create_habit(name, period)
            click.echo(f'Habit "{name}" with period "{period}" created successfully!')
        elif command == 'delete':
            name = input('Enter the habit name: ')
            tracker.delete_habit(name)
            click.echo(f'Habit "{name}" deleted successfully!')
        elif command == 'habit_groups':
            habits = tracker.get_habits()
            click.echo('Current habits:')
            for habit in habits:
                click.echo(f'- {habit.name} ({habit.period})')
        elif command == 'habit_groups_period':
            period = input('Enter the habit period (daily or weekly): ')
            habits = tracker.get_habits_by_period(period)
            click.echo(f'Current {period} habits:')
            for habit in habits:
                click.echo(f'- {habit.name}')
        elif command == 'longest-streak':
            longest_streak_habit = tracker.get_longest_streak()
            if longest_streak_habit:
                click.echo(f'Habit with longest streak: {longest_streak_habit.name} ({longest_streak_habit.get_streak()})')
            else:
                click.echo('No habits with a streak.')
        elif command == 'longest-streak-habit':
            name = input('Enter the habit name: ')
            streak = tracker.get_longest_streak_by_habit(name)
            if streak:
                click.echo(f'Longest streak for habit "{name}": {streak}')
            else:
                click.echo(f'Habit "{name}" not found.')
        elif command == 'mark':
            name = input('Enter the habit name: ')
            tracker.mark_complete(name)
            click.echo(f'Habit "{name}" marked successfully!')
        elif command == 'unmark':
            name = input('Enter the habit name: ')
            tracker.mark_incomplete(name)
            click.echo(f'Habit "{name}" unmarked successfully!')
        elif command == 'exit':
            break

if __name__ == '__main__':
    cli()
    main()
habit.py file:

 
from datetime import datetime



class Habit:
    def __init__(self, name: str, period: str):
        self.name = name
        self.period = period
        self.created_at = datetime.now()
        self.completed_at = []
    
    def mark_complete(self):
        self.completed_at.append(datetime.now())
        
    def mark_incomplete(self):
        self.completed_at.pop()
    
    def get_streak(self):
        if not self.completed_at:
            return 0
        current_streak = 1
        for i in range(1, len(self.completed_at)):
            if self.completed_at[i] - self.completed_at[i-1] == self.period:
                current_streak += 1
            else:
                break
        return current_streak

class HabitTracker:
    def __init__(self):
        self.habits = []
    
    def create_habit(self, name: str, period: str):
        new_habit = Habit(name, period)
        self.habits.append(new_habit)
    
    def delete_habit(self, name: str):
        for i, habit in enumerate(self.habits):
            if habit.name == name:
                del self.habits[i]
                break
    
    def get_habits(self):
        return self.habits
    
    def get_habits_by_period(self, period: str):
        return [habit for habit in self.habits if habit.period == period]
    
    def get_longest_streak(self):
        longest_streak = 0
        longest_streak_habit = None
        for habit in self.habits:
            streak = habit.get_streak()
            if streak > longest_streak:
                longest_streak = streak
                longest_streak_habit = habit
        return longest_streak_habit
    
    def get_longest_streak_by_habit(self, name: str):
        for habit in self.habits:
            if habit.name == name:
                return habit.get_streak()
        return 0
    
    def mark_complete(self, name: str):
        for habit in self.habits:
            if habit.name == name:
                habit.mark_complete()
                break
            
    def mark_incomplete(self, name: str):
        for i, completed_at in enumerate(self.completed_at):
            if self.name == name:
                del self.completed_at[i] 
            
    def relationships(self):
        for habit in self.habits:
            print(habit.name, habit.get_streak())
 
db.py file:

 
import sqlite3
import datetime
from habit import Habit, HabitTracker


class HabitDB:
    def establish_a_connection(self):
        self.conn = sqlite3.connect('habits.db')
        self.cursor = self.conn.cursor()

    # Create habits table if it does not exist
        self.cursor.execute('''
                CREATE TABLE IF NOT EXISTS habits (
                id INTEGER PRIMARY KEY,
                name TEXT NOT NULL,
                period TEXT NOT NULL,
                created_at DATETIME NOT NULL
            )''')

    # Create completions table if it does not exist 
        self.cursor.execute(''' 
                CREATE TABLE IF NOT EXISTS completions ( 
                habit_id INTEGER NOT NULL, 
                completed_at DATETIME NOT NULL, 
                FOREIGN KEY(habit_id) REFERENCES habits(id) ON 
                DELETE CASCADE  //added ON DELETE CASCADE to delete the related entries in completions when a row in habits is deleted 
            )''')
        self.conn.commit()

    def create_habit(self, name: str, period: str):
        self.cursor.execute(
            'INSERT INTO habits (name, period, created_at) VALUES (?, ?, ?)',
            (name, period, datetime.now())
        )
        self.conn.commit()

    def delete_habit(self, name: str):
        self.cursor.execute(
            'DELETE FROM habits WHERE name=?',
            (name,)
        )
        self.conn.commit()

    def mark_complete(self, name: str):
        self.cursor.execute(
            'SELECT id FROM habits WHERE name=?',
            (name,)
        )
        habit_id = self.cursor.fetchone()[0]
        self.cursor.execute(
            'INSERT INTO completions (habit_id, completed_at) VALUES (?, ?)',
            (habit_id, datetime.now())
        )
        self.conn.commit()

    def mark_incomplete(self, name: str):
        self.cursor.execute(
            'SELECT id FROM habits WHERE name=?',
            (name,)
        )
        habit_id = self.cursor.fetchone()[0]
        self.cursor.execute(
            'DELETE FROM completions WHERE habit_id=? ORDER BY completed_at DESC LIMIT 1',
            (habit_id,)
        )
        self.conn.commit()

    
    def get_habits(self):
        self.cursor.execute('SELECT * FROM habits')
        rows = self.cursor.fetchall()
        habits = []
        for row in rows:
            id, name, period, createdat = row
            self.cursor.execute(
            'SELECT completedat FROM completions WHERE habitid=?',
                (id,))
            completedatrows = self.cursor.fetchall()
            completedat = [row[0] for row in completedatrows]
            habits.append(Habit(id, name, period, createdat, completedat))
        return habits


    def get_habits_by_period(self, period: str):
        self.cursor.execute('SELECT * FROM habits WHERE period=?', (period,))
        rows = self.cursor.fetchall()
        habits = []
        for row in rows:
            id, name, _, created_at = row
            self.cursor.execute(
                'SELECT completed_at FROM completions WHERE habit_id=?',
                (id,)
            )
            completed_at_rows = self.cursor.fetchall()
            
class HabitTracker:
    def __init__(self):
        self.habits = []
        self.db = HabitDB()
        self.db.establish_a_connection()

    def create_habit(self, name: str, period: str):
        self.db.create_habit(name, period)

    def delete_habit(self, name: str):
        self.db.delete_habit(name)

    def get_habits(self):
        return self.db.get_habits()

    def get_habits_by_period(self, period: str):
        return self.db.get_habits_by_period(period)

    def get_longest_streak(self):
        longest_streak = 0
        longest_streak_habit = None
        for habit in self.habits:
            streak = habit.get_streak()
            if streak > longest_streak:
                longest_streak = streak
                longest_streak_habit = habit
        return longest_streak_habit

    def get_longest_streak_by_habit(self, name: str):
        for habit in self.habits:
            if habit.name == name:
                return habit.get_streak()
        return 0

    def mark_complete(self, name: str):
        self.db.mark_complete(name)

    def mark_incomplete(self, name: str):
        self.db.mark_incomplete(name)

    def get_longest_streak_by_habit(self, name: str):
        for habit in self.habits:
            if habit.name == name:
                return habit.get_streak()
        return 0

    def mark_complete(self, name: str):
        self.db.mark_complete(name)

    def mark_incomplete(self, name: str):
        self.db.mark_incomplete(name) 
Larz60+ write Feb-13-2023, 05:35 PM:
Please post all code, output and errors (it it's entirety) between their respective tags. Refer to BBCode help topic on how to post. Use the "Preview Post" button to make sure the code is presented as you expect before hitting the "Post Reply/Thread" button.
Reply
#2
Couple of things, after a brief flight over the code

https://github.com/Bcopeland64/IU-Habit-...ain.py#L70
What is the purpose of main()? You never call it and also it repeats the different cli commands - you can always organise it better, e.g. using dict, instead of this huge if/elif/else


https://github.com/Bcopeland64/IU-Habit-...bit.py#L70
don't change list while iterating over it. Iterate over copy of the list instead:
for i, completed_at in enumerate(self.completed_at[::]):
In all SELECT queries - you will get IndexError if no result is returned (i.e. empty result set), e.g.
 self.cursor.execute(
            'SELECT id FROM habits WHERE name=?',
            (name,)
        )
        habit_id = self.cursor.fetchone()[0]
if name is not present and fetchone() returns empty tuple, there is no index 0
BCopeland64 likes this post
If you can't explain it to a six year old, you don't understand it yourself, Albert Einstein
How to Ask Questions The Smart Way: link and another link
Create MCV example
Debug small programs

Reply
#3
With the main(), I thought that I needed this to run my interface, however it seems that it is completely unneeded. Also, can you suggest a better way to iterate over the copy of the list rather than changing it as you mentioned?

I am also glad that you mentioned the select statements because it is returning empty lists. I have struggled with the db element for a while now and why it isn't returning populated lists (i.e. adding them to the db like it should). The maddening thing is that when I run my app, I can create habits etc, but when I run the function to list them, it comes up empty and with no traceback or error messages (at least in my IDE: VSCode). What am I doing wrong there?

Thanks for your assistance. I am a real noob!
Reply
#4
(Feb-15-2023, 05:46 PM)BCopeland64 Wrote: Also, can you suggest a better way to iterate over the copy of the list rather than changing it as you mentioned?

Compare

spam = [1, 2, 2, 3]

for item in spam:
    if item == 2:
        spam.remove(item)

print(spam)
Output:
[1, 2, 3]
and
spam = [1, 2, 2, 3]

for item in spam[::]:
    if item == 2:
        spam.remove(item)

print(spam)
Output:
[1, 3]
It may be irrelevant in your case, but it's not clear if you want to allow duplicate names. In mark_complete you break immediatelly when name is found (it implies no duplicate names by design) but in mark_incomplete you iterate over whole list and this imply duplicate names.

Also, note that I use list.remove, not del list[i]
But my preference will be
spam = [1, 2, 2, 3]
spam = [item for item in spam if item != 2]
print(spam)
Check https://stackoverflow.com/q/1207406/4046632
With regards to DB part - there are plenty of trivial errors in the db.py that do not allow the code to run, I mean not run at all
If you can't explain it to a six year old, you don't understand it yourself, Albert Einstein
How to Ask Questions The Smart Way: link and another link
Create MCV example
Debug small programs

Reply
#5
(Feb-15-2023, 07:21 PM)buran Wrote:
(Feb-15-2023, 05:46 PM)BCopeland64 Wrote: Also, can you suggest a better way to iterate over the copy of the list rather than changing it as you mentioned?

Compare

spam = [1, 2, 2, 3]

for item in spam:
    if item == 2:
        spam.remove(item)

print(spam)
Output:
[1, 2, 3]
and
spam = [1, 2, 2, 3]

for item in spam[::]:
    if item == 2:
        spam.remove(item)

print(spam)
Output:
[1, 3]
It may be irrelevant in your case, but it's not clear if you want to allow duplicate names. In mark_complete you break immediatelly when name is found (it implies no duplicate names by design) but in mark_incomplete you iterate over whole list and this imply duplicate names.

Also, note that I use list.remove, not del list[i]
But my preference will be
spam = [1, 2, 2, 3]
spam = [item for item in spam if item != 2]
print(spam)
Check https://stackoverflow.com/q/1207406/4046632
With regards to DB part - there are plenty of trivial errors in the db.py that do not allow the code to run, I mean not run at all

Thanks again for the assistance. I think I fixed my habit and main.py files. I don't know where to begin on the db.py file since those errors escape me. Are we talking about indentation, the SQL, variables? I wrote a tester.db file that works just fine using the same logic, but in my original db.py file when I put the same logic behind classes and functions, it all goes out the window.
Reply
#6
https://github.com/Bcopeland64/IU-Habit-.../db.py#L26
DELETE CASCADE  //added ON DELETE CASCADE to delete the related entries in completions when a row in habits is deleted
The "comment" is not valid SQL syntax for comment and this raise error.
After removing the comment the error is fixed.

Then when you try to create habit there is problem in create_habit with datetime.now()
https://github.com/Bcopeland64/IU-Habit-.../db.py#L32
You import datetime, however datetime module does not has now() method. You need to from datetime import datetime in order for datetime.now() to work.

After fixing the import, there is problem with get_habit
https://github.com/Bcopeland64/IU-Habit-.../db.py#L78
when you instantiate Habit() you pass more arguments than expected.

At that point I gave up
And something I didn't realise at first glance
You actually never use db.HabitDB. You only work with habit.HabitTracker. i.e. you never actually use the database part. The instance of HabitTracker class is in-memory and all data is lost when program finish
If you can't explain it to a six year old, you don't understand it yourself, Albert Einstein
How to Ask Questions The Smart Way: link and another link
Create MCV example
Debug small programs

Reply
#7
(Feb-16-2023, 11:20 AM)buran Wrote: https://github.com/Bcopeland64/IU-Habit-.../db.py#L26
DELETE CASCADE  //added ON DELETE CASCADE to delete the related entries in completions when a row in habits is deleted
The "comment" is not valid SQL syntax for comment and this raise error.
After removing the comment the error is fixed.

Then when you try to create habit there is problem in create_habit with datetime.now()
https://github.com/Bcopeland64/IU-Habit-.../db.py#L32
You import datetime, however datetime module does not has now() method. You need to from datetime import datetime in order for datetime.now() to work.

After fixing the import, there is problem with get_habit
https://github.com/Bcopeland64/IU-Habit-.../db.py#L78
when you instantiate Habit() you pass more arguments than expected.

At that point I gave up
And something I didn't realise at first glance
You actually never use db.HabitDB. You only work with habit.HabitTracker. i.e. you never actually use the database part. The instance of HabitTracker class is in-memory and all data is lost when program finish

As always, thanks for all of your help. I will go back to the drawing board. I really appreciate your patience.
Reply
#8
This is the newest app. I think this one will hopefully work a little better.

main.py:
import datetime
import questionary
from db import HabitDB
from habit import Habit

def main():
    while True:
        choices = [
            {'name': 'Create a habit', 'value': create_habit},
            {'name': 'List habits', 'value': list_habits},
            {'name': 'Mark habit complete', 'value': mark_complete},
            {'name': 'Mark habit incomplete', 'value': mark_incomplete},
            {'name': 'Delete a habit', 'value': delete_habit},
            {'name': 'Exit', 'value': exit}
        ]
        choice = questionary.select('What would you like to do?', choices=choices).ask()

        if choice:
            choice()

def create_habit():
    name = questionary.text('Enter the name of the habit:').ask()
    frequency = questionary.select('How often should this habit be completed?', choices=['daily', 'weekly', 'monthly']).ask()
    completed = False
    habit = HabitDB(name, frequency)
    habit.save()
    print(f'Habit "{name}" with frequency "{frequency}" created successfully!')

def list_habits():
    habits = Habit.list_all()
    for habit in habits:
        print(f'{habit.name} ({habit.frequency} days) - {"Complete" if habit.completed else "Incomplete"}')

def mark_complete():
    habits = Habit.list_all()
    habit_choices = [{'name': habit.name, 'value': habit} for habit in habits]
    habit = questionary.select('Which habit would you like to mark as complete?', choices=habit_choices).ask()
    habit.mark_complete()
    print(f'Habit "{habit.name}" marked as complete!')

def mark_incomplete():
    habits = Habit.list_all()
    habit_choices = [{'name': habit.name, 'value': habit} for habit in habits]
    habit = questionary.select('Which habit would you like to mark as incomplete?', choices=habit_choices).ask()
    habit.mark_incomplete()
    print(f'Habit "{habit.name}" marked as incomplete!')

def delete_habit():
    habits = Habit.list_all()
    habit_choices = [{'name': habit.name, 'value': habit} for habit in habits]
    habit = questionary.select('Which habit would you like to delete?', choices=habit_choices).ask()
    habit.delete()
    print(f'Habit "{habit.name}" deleted successfully!')

if __name__ == '__main__':
    main()
db.py:
import sqlite3
from datetime import datetime
from habit import Habit

class HabitDB:
    def __init__(self, name, frequency):
        self.name = name
        self.frequency = frequency
        self.completed = []
        
    
    def mark_complete(self):
        today = datetime.date.today()
        self.completed.append(today)

    def is_complete(self):
        if self.frequency == 'daily':
            return datetime.date.today() in self.completed
        elif self.frequency == 'weekly':
            start_of_week = (datetime.date.today() - datetime.timedelta(days=datetime.date.today().weekday()))
            end_of_week = start_of_week + datetime.timedelta(days=6)
            for day in range((end_of_week - start_of_week).days + 1):
                date = start_of_week + datetime.timedelta(days=day)
                if date in self.completed:
                    return True
            return False
        elif self.frequency == 'monthly':
            today = datetime.date.today()
            if today.day >= 28:
                end_of_month = today.replace(day=28) + datetime.timedelta(days=4)
                for day in range((end_of_month - today).days + 1):
                    date = today + datetime.timedelta(days=day)
                    if date in self.completed:
                        return True
                return False
            else:
                return today.replace(day=1) in self.completed

    def delete(self):
        with sqlite3.connect('habits.db') as conn:
            cursor = conn.cursor()
            cursor.execute('DELETE FROM habits WHERE name = ?', (self.name,))

    @staticmethod
    def list_by_frequency(frequency):
        with sqlite3.connect('habits.db') as conn:
            cursor = conn.cursor()
            cursor.execute('SELECT name FROM habits WHERE frequency = ?', (frequency,))
            return [row[0] for row in cursor.fetchall()]

    def save(self):
        with sqlite3.connect('habits.db') as conn:
            cursor = conn.cursor()
            cursor.execute('INSERT INTO habits (name, frequency, completed) VALUES (?, ?, ?)', (self.name, str(self.frequency), self.completed))


    def update(self):
        with sqlite3.connect('habits.db') as conn:
            cursor = conn.cursor()
            cursor.execute('UPDATE habits SET completed = ? WHERE name = ?', (self.completed, self.name))

    @staticmethod
    def get_all_habits():
        with sqlite3.connect('habits.db') as conn:
            cursor = conn.cursor()
            cursor.execute('SELECT name, frequency, completed FROM habits')
            rows = cursor.fetchall()
            habits = []
            for row in rows:
                habit = Habit(row[0], row[1])
                habit.completed = [datetime.date.fromisoformat(date_str) for date_str in row[2].split(',')]
                habits.append(habit)
            return habits

def create_table():
    with sqlite3.connect('habits.db') as conn:
        cursor = conn.cursor()
        cursor.execute('''CREATE TABLE IF NOT EXISTS habits
                          (name TEXT PRIMARY KEY, frequency TEXT, completed TEXT)''')
habit.py:
from datetime import datetime, date

class Habit:
    def __init__(self, name, frequency):
        self.name = name
        self.frequency = frequency
        self.completed = []

    def mark_complete(self):
        today = datetime.now()
        self.completed.append(today)
        
    def mark_incomplete(self):
        today = datetime.now()
        self.completed.remove(today)
        

    def is_complete(self):
        if self.frequency == 'daily':
            return datetime.now() in self.completed
        elif self.frequency == 'weekly':
            start_of_week = (datetime.date.now() - datetime.timedelta(days=datetime.now().weekday()))
            end_of_week = start_of_week + datetime.timedelta(days=6)
            for day in range((end_of_week - start_of_week).days + 1):
                date = start_of_week + datetime.timedelta(days=day)
                if date in self.completed:
                    return True
            return False
        elif self.frequency == 'monthly':
            today = datetime.now()
            if today.day >= 28:
                end_of_month = today.replace(day=28) + datetime.timedelta(days=4)
                for day in range((end_of_month - today).days + 1):
                    date = today + datetime.timedelta(days=day)
                    if date in self.completed:
                        return True
                return False
            else:
                return today.replace(day=1) in self.completed

    def delete(self):
        del self
        

    @staticmethod
    def list_by_frequency(habits, frequency):
        result = []
        for habit in habits:
            if habit.frequency == frequency:
                result.append(habit.name)
        return result
    
    def list_all(habits):
        result = []
        for habit in habits:
            result.append(habit.name)
        return result
    
    
Reply


Forum Jump:

User Panel Messages

Announcements
Announcement #1 8/1/2020
Announcement #2 8/2/2020
Announcement #3 8/6/2020