diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 64829ee..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: build and release - -on: [push] - -jobs: - test: - name: pytest - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: '3.10' - - run: pip install -r requirements.txt pytest - - uses: cclauss/GitHub-Action-for-pytest@0.5.0 - - run: pytest - - build: - needs: - - test - strategy: - matrix: - os: - - ubuntu - - windows - - macos - architecture: ['x64'] - app: ['cli', updater] - include: - - os: windows - data-file: data/schema.sql;. - name: windows - - os: macos - data-file: data/schema.sql:. - name: macos - - os: ubuntu - data-file: data/schema.sql:. - name: linux - runs-on: ${{ matrix.os }}-latest - name: ${{ matrix.app }}-${{ matrix.name }}-${{ matrix.architecture }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: '3.10' - architecture: ${{ matrix.architecture }} - - run: pip install -r requirements.txt pyinstaller - - run: mkdir build - - run: mkdir bin - - run: pyinstaller --distpath bin --clean --add-data "${{ matrix.data-file }}" --onefile --name npbc_${{ matrix.app }}-${{ matrix.name }}-${{ matrix.architecture }} npbc_${{ matrix.app }}.py - - uses: actions/upload-artifact@v2 - with: - path: bin - name: npbc_${{ matrix.app }}-${{ matrix.name }}-${{ matrix.architecture }} - - release: - needs: - - build - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v2 - - run: mkdir bin - - uses: actions/download-artifact@v2 - with: - path: bin - - uses: ncipollo/release-action@v1 - with: - artifacts: "bin/npbc*/*" - token: ${{ secrets.GITHUB_TOKEN }} - generateReleaseNotes: true - artifactErrorsFailBuild: true - prerelease: false diff --git a/.github/workflows/test-build-release.yml b/.github/workflows/test-build-release.yml new file mode 100644 index 0000000..60f3cff --- /dev/null +++ b/.github/workflows/test-build-release.yml @@ -0,0 +1,162 @@ +name: Test, build and release + +on: [push] + +jobs: + + # test with pytest + test: + name: pytest + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + # build SQLite from source, because I need 3.35<= + - run: | + wget https://sqlite.org/2022/sqlite-autoconf-3380500.tar.gz + tar -xvf sqlite-autoconf-3380500.tar.gz + - run: | + ./configure + make + sudo make install + export PATH="/usr/local/lib:$PATH" + working-directory: sqlite-autoconf-3380500 + + # run pytest + - uses: actions/setup-python@v2 + with: + python-version: '3.10' + - run: pip install -r requirements.txt pytest + - run: pytest + env: + LD_LIBRARY_PATH: /usr/local/lib + + # build executable for linux + build-linux: + name: build for linux + runs-on: ubuntu-latest + needs: test + + steps: + + # setup + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - run: | + pip install pyinstaller + mkdir bin + mkdir build + + # build + - run: | + pyinstaller --distpath bin --clean --onefile --name npbc_updater-linux-x64 npbc_updater.py + pip install -r requirements.txt + pyinstaller --distpath bin --clean --add-data "data/schema.sql:." --onefile --name npbc_cli-linux-x64 npbc_cli.py + + # upload artifacts + - uses: actions/upload-artifact@v2 + with: + path: bin + name: npbc_cli-linux-x64 + - uses: actions/upload-artifact@v2 + with: + path: bin + name: npbc_updater-linux-x64 + + # build executable for windows + build-windows: + name: build for windows + runs-on: windows-latest + needs: test + + steps: + + # setup + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - run: | + pip install pyinstaller + mkdir bin + mkdir build + + # build + - run: | + pyinstaller --distpath bin --clean --onefile --name npbc_updater-windows-x64 npbc_updater.py + pip install -r requirements.txt + pyinstaller --distpath bin --clean --add-data "data/schema.sql;." --onefile --name npbc_cli-windows-x64 npbc_cli.py + + # upload artifacts + - uses: actions/upload-artifact@v2 + with: + path: bin + name: npbc_cli-windows-x64 + - uses: actions/upload-artifact@v2 + with: + path: bin + name: npbc_updater-windows-x64 + + # build executable for macos + build-macos: + name: build for macos + runs-on: macos-latest + needs: test + + steps: + + # setup + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - run: | + pip install pyinstaller + mkdir bin + mkdir build + + # build + - run: | + pyinstaller --distpath bin --clean --onefile --name npbc_updater-macos-x64 npbc_updater.py + pip install -r requirements.txt + pyinstaller --distpath bin --clean --add-data "data/schema.sql:." --onefile --name npbc_cli-macos-x64 npbc_cli.py + + # upload artifacts + - uses: actions/upload-artifact@v2 + with: + path: bin + name: npbc_cli-macos-x64 + - uses: actions/upload-artifact@v2 + with: + path: bin + name: npbc_updater-macos-x64 + + # create release from tag + release: + + # ensure that build is complete for all platforms + needs: + - build-linux + - build-macos + - build-windows + + # run only if we're on a tag beginning with 'v' ('v1.2.5', for example) + if: startsWith(github.ref, 'refs/tags/v') + + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + + # download the artifacts + - run: mkdir bin + - uses: actions/download-artifact@v2 + with: + path: bin + + # do the release + - uses: ncipollo/release-action@v1 + with: + artifacts: "bin/npbc*/*" + token: ${{ secrets.GITHUB_TOKEN }} + generateReleaseNotes: true + artifactErrorsFailBuild: true + prerelease: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1a796ae..903704f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,12 @@ __pycache__ *build *dist .vs -exp +exp* *legacy* -npbc.db -pyenv-install bin -.vscode \ No newline at end of file +.vscode +.pytest_cache +data/* +!data/test.sql +!data/schema.sql +.env diff --git a/README.md b/README.md index 7cc7e48..7eec8a7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This app calculates your monthly newspaper bill. 2. Each newspaper may or may not be delivered on a given day 3. Each newspaper has a name, and a number called a key 4. You may register any dates when you didn't receive a paper in advance using the `addudl` command -5. Once you calculate, the results are displayed and copied to your clipboard +5. Once you calculate, the results are displayed and logged. ## Installation 1. From [the latest release](https://github.com/eccentricOrange/npbc/releases/latest), download the "updater" file for your operating system in any folder, and make it executable. diff --git a/data/schema.sql b/data/schema.sql index 3d59e04..65ead90 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -3,36 +3,51 @@ CREATE TABLE IF NOT EXISTS papers ( name TEXT NOT NULL, CONSTRAINT unique_paper_name UNIQUE (name) ); -CREATE TABLE IF NOT EXISTS papers_days_delivered ( - paper_id INTEGER NOT NULL, + +CREATE TABLE IF NOT EXISTS papers_days ( + paper_day_id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_id INTEGER NOT NULL REFERENCES papers(paper_id), day_id INTEGER NOT NULL, - delivered INTEGER NOT NULL, - FOREIGN KEY(paper_id) REFERENCES papers(paper_id), CONSTRAINT unique_paper_day UNIQUE (paper_id, day_id) ); -CREATE TABLE IF NOT EXISTS papers_days_cost( - paper_id INTEGER NOT NULL, - day_id INTEGER NOT NULL, - cost INTEGER, - FOREIGN KEY(paper_id) REFERENCES papers(paper_id), - CONSTRAINT unique_paper_day UNIQUE (paper_id, day_id) + +CREATE TABLE IF NOT EXISTS papers_days_delivered ( + papers_days_delivered_id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_day_id INTEGER NOT NULL REFERENCES papers_days(paper_day_id), + delivered INTEGER NOT NULL CHECK (delivered IN (0, 1)) +); + +CREATE TABLE IF NOT EXISTS papers_days_cost ( + papers_days_cost_id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_day_id INTEGER NOT NULL REFERENCES papers_days(paper_day_id), + cost REAL ); + CREATE TABLE IF NOT EXISTS undelivered_strings ( - entry_id INTEGER PRIMARY KEY AUTOINCREMENT, - year INTEGER NOT NULL, - month INTEGER NOT NULL, - paper_id INTEGER NOT NULL, - string TEXT NOT NULL, - FOREIGN KEY (paper_id) REFERENCES papers(paper_id) + string_id INTEGER PRIMARY KEY AUTOINCREMENT, + year INTEGER NOT NULL CHECK (year >= 0), + month INTEGER NOT NULL CHECK (month >= 0 AND month <= 12), + paper_id INTEGER NOT NULL REFERENCES papers(paper_id), + string TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS undelivered_dates ( - entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + +CREATE TABLE IF NOT EXISTS logs ( + log_id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_id INTEGER NOT NULL REFERENCES papers(paper_id), timestamp TEXT NOT NULL, - year INTEGER NOT NULL, - month INTEGER NOT NULL, - paper_id INTEGER NOT NULL, - dates TEXT NOT NULL, - FOREIGN KEY (paper_id) REFERENCES papers(paper_id) + month INTEGER NOT NULL CHECK (month >= 0 AND month <= 12), + year INTEGER NOT NULL CHECK (year >= 0), + CONSTRAINT unique_log UNIQUE (timestamp, paper_id, month, year) +); + +CREATE TABLE IF NOT EXISTS undelivered_dates_logs ( + undelivered_dates_log_id INTEGER PRIMARY KEY AUTOINCREMENT, + log_id INTEGER NOT NULL REFERENCES logs(log_id), + date_not_delivered TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS search_strings ON undelivered_strings(year, month); -CREATE INDEX IF NOT EXISTS paper_names ON papers(name); \ No newline at end of file + +CREATE TABLE IF NOT EXISTS cost_logs ( + cost_log_id INTEGER PRIMARY KEY AUTOINCREMENT, + log_id INTEGER NOT NULL REFERENCES logs(log_id), + cost REAL NOT NULL +); \ No newline at end of file diff --git a/data/test.sql b/data/test.sql new file mode 100644 index 0000000..0f7e419 --- /dev/null +++ b/data/test.sql @@ -0,0 +1,31 @@ +INSERT INTO papers (name) +VALUES + ('paper1'), + ('paper2'), + ('paper3'); + +INSERT INTO papers_days (paper_id, day_id) +VALUES + (1, 0), (1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 0), (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (2, 6), + (3, 0), (3, 1), (3, 2), (3, 3), (3, 4), (3, 5), (3, 6); + +INSERT INTO papers_days_delivered (paper_day_id, delivered) +VALUES + (01, 0), (02, 1), (03, 0), (04, 0), (05, 0), (06, 1), (07, 1), + (08, 0), (09, 0), (10, 0), (11, 0), (12, 1), (13, 0), (14, 1), + (15, 1), (16, 1), (17, 0), (18, 0), (19, 1), (20, 1), (21, 1); + +INSERT INTO papers_days_cost (paper_day_id, cost) +VALUES + (01, 0.0), (02, 6.4), (03, 0.0), (04, 0.0), (05, 0.0), (06, 7.9), (07, 4.0), + (08, 0.0), (09, 0.0), (10, 0.0), (11, 0.0), (12, 3.4), (13, 0.0), (14, 8.4), + (15, 2.4), (16, 4.6), (17, 0.0), (18, 0.0), (19, 3.4), (20, 4.6), (21, 6.0); + +INSERT INTO undelivered_strings (year, month, paper_id, string) +VALUES + (2020, 11, 1, '5'), + (2020, 11, 1, '6-12'), + (2020, 11, 2, 'sundays'), + (2020, 11, 3, '2-tuesday'), + (2020, 10, 3, 'all'); \ No newline at end of file diff --git a/npbc_cli.py b/npbc_cli.py index a0853e2..2874d8f 100644 --- a/npbc_cli.py +++ b/npbc_cli.py @@ -1,21 +1,31 @@ +""" +wraps a CLI around the core functionality (using argparse) +- inherits functionality from `npbc_core.py` +- inherits regex from `npbc_regex.py`, used for validation +- inherits exceptions from `npbc_exceptions.py`, used for error handling +- performs some additional validation +- formats data retrieved from the core for the user +""" + + +import sqlite3 from argparse import ArgumentParser -from argparse import Namespace as arg_namespace +from argparse import Namespace as ArgNamespace from datetime import datetime +from typing import Generator from colorama import Fore, Style -from pyperclip import copy as copy_to_clipboard -from npbc_core import (VALIDATE_REGEX, WEEKDAY_NAMES, add_new_paper, - add_undelivered_string, calculate_cost_of_all_papers, - delete_existing_paper, delete_undelivered_string, - edit_existing_paper, extract_days_and_costs, - format_output, generate_sql_query, get_previous_month, - query_database, save_results, setup_and_connect_DB, - validate_month_and_year, validate_undelivered_string) +import npbc_core +import npbc_exceptions +from npbc_regex import DELIVERY_MATCH_REGEX -## setup parsers -def define_and_read_args() -> arg_namespace: +def define_and_read_args() -> ArgNamespace: + """configure parsers + - define the main parser for the application executable + - define subparsers (one for each functionality) + - parse the arguments""" # main parser for all commands main_parser = ArgumentParser( @@ -33,56 +43,64 @@ def define_and_read_args() -> arg_namespace: calculate_parser.set_defaults(func=calculate) calculate_parser.add_argument('-m', '--month', type=int, help="Month to calculate bill for. Must be between 1 and 12.") - calculate_parser.add_argument('-y', '--year', type=int, help="Year to calculate bill for. Must be between 1 and 9999.") - calculate_parser.add_argument('-c', '--nocopy', help="Don't copy the result of the calculation to the clipboard.", action='store_true') + calculate_parser.add_argument('-y', '--year', type=int, help="Year to calculate bill for. Must be greater than 0.") calculate_parser.add_argument('-l', '--nolog', help="Don't log the result of the calculation.", action='store_true') + # add undelivered string subparser addudl_parser = functions.add_parser( 'addudl', - help="Store a date when paper(s) were not delivered. Previous month will be used if month or year flags are not set." + help="Store a date when paper(s) were not delivered. Current month will be used if month or year flags are not set. Either paper ID must be provided, or the all flag must be set." ) addudl_parser.set_defaults(func=addudl) addudl_parser.add_argument('-m', '--month', type=int, help="Month to register undelivered incident(s) for. Must be between 1 and 12.") - addudl_parser.add_argument('-y', '--year', type=int, help="Year to register undelivered incident(s) for. Must be between 1 and 9999.") - addudl_parser.add_argument('-k', '--key', type=str, help="Key of paper to register undelivered incident(s) for.", required=True) - addudl_parser.add_argument('-u', '--undelivered', type=str, help="Dates when you did not receive any papers.", required=True) + addudl_parser.add_argument('-y', '--year', type=int, help="Year to register undelivered incident(s) for. Must be greater than 0.") + addudl_parser.add_argument('-p', '--paperid', type=str, help="ID of paper to register undelivered incident(s) for.") + addudl_parser.add_argument('-a', '--all', help="Register undelivered incidents for all papers.", action='store_true') + addudl_parser.add_argument('-s', '--strings', type=str, help="Dates when you did not receive any papers.", required=True, nargs='+') + # delete undelivered string subparser deludl_parser = functions.add_parser( 'deludl', - help="Delete a stored date when paper(s) were not delivered. Previous month will be used if month or year flags are not set." + help="Delete a stored date when paper(s) were not delivered. If no parameters are provided, the function will not default; it will throw an error instead." ) deludl_parser.set_defaults(func=deludl) - deludl_parser.add_argument('-k', '--key', type=str, help="Key of paper to unregister undelivered incident(s) for.", required=True) - deludl_parser.add_argument('-m', '--month', type=int, help="Month to unregister undelivered incident(s) for. Must be between 1 and 12.", required=True) - deludl_parser.add_argument('-y', '--year', type=int, help="Year to unregister undelivered incident(s) for. Must be between 1 and 9999.", required=True) + deludl_parser.add_argument('-p', '--paperid', type=str, help="ID of paper to unregister undelivered incident(s) for.") + deludl_parser.add_argument('-i', '--stringid', type=str, help="String ID of paper to unregister undelivered incident(s) for.") + deludl_parser.add_argument('-m', '--month', type=int, help="Month to unregister undelivered incident(s) for. Must be between 1 and 12.") + deludl_parser.add_argument('-y', '--year', type=int, help="Year to unregister undelivered incident(s) for. Must be greater than 0.") + deludl_parser.add_argument('-s', '--string', type=str, help="Dates when you did not receive any papers.") + # get undelivered string subparser getudl_parser = functions.add_parser( 'getudl', - help="Get a list of all stored date strings when paper(s) were not delivered." + help="Get a list of all stored date strings when paper(s) were not delivered. All parameters are optional and act as filters." ) getudl_parser.set_defaults(func=getudl) - getudl_parser.add_argument('-k', '--key', type=str, help="Key for paper.") + getudl_parser.add_argument('-p', '--paperid', type=str, help="ID for paper.") + getudl_parser.add_argument('-i', '--stringid', type=str, help="String ID of paper to unregister undelivered incident(s) for.") getudl_parser.add_argument('-m', '--month', type=int, help="Month. Must be between 1 and 12.") - getudl_parser.add_argument('-y', '--year', type=int, help="Year. Must be between 1 and 9999.") - getudl_parser.add_argument('-u', '--undelivered', type=str, help="Dates when you did not receive any papers.") + getudl_parser.add_argument('-y', '--year', type=int, help="Year. Must be greater than 0.") + getudl_parser.add_argument('-s', '--string', type=str, help="Dates when you did not receive any papers.") + # edit paper subparser editpaper_parser = functions.add_parser( 'editpaper', - help="Edit a newspaper\'s name, days delivered, and/or price." + help="Edit a newspaper's name, days delivered, and/or price." ) editpaper_parser.set_defaults(func=editpaper) editpaper_parser.add_argument('-n', '--name', type=str, help="Name for paper to be edited.") - editpaper_parser.add_argument('-d', '--days', type=str, help="Number of days the paper to be edited is delivered. Monday is the first day, and all seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't. No separator required.") - editpaper_parser.add_argument('-p', '--price', type=str, help="Daywise prices of paper to be edited. Monday is the first day. Values must be separated by semicolons, and 0s are ignored.") - editpaper_parser.add_argument('-k', '--key', type=str, help="Key for paper to be edited.", required=True) + editpaper_parser.add_argument('-d', '--delivered', type=str, help="Number of days the paper to be edited is delivered. All seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't. No separator required.") + editpaper_parser.add_argument('-c', '--costs', type=str, help="Daywise prices of paper to be edited. 0s are ignored.", nargs='*') + editpaper_parser.add_argument('-p', '--paperid', type=str, help="ID for paper to be edited.", required=True) + # add paper subparser addpaper_parser = functions.add_parser( @@ -92,8 +110,9 @@ def define_and_read_args() -> arg_namespace: addpaper_parser.set_defaults(func=addpaper) addpaper_parser.add_argument('-n', '--name', type=str, help="Name for paper to be added.", required=True) - addpaper_parser.add_argument('-d', '--days', type=str, help="Number of days the paper to be added is delivered. Monday is the first day, and all seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't. No separator required.", required=True) - addpaper_parser.add_argument('-p', '--price', type=str, help="Daywise prices of paper to be added. Monday is the first day. Values must be separated by semicolons, and 0s are ignored.", required=True) + addpaper_parser.add_argument('-d', '--delivered', type=str, help="Number of days the paper to be added is delivered. All seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't. No separator required.", required=True) + addpaper_parser.add_argument('-c', '--costs', type=str, help="Daywise prices of paper to be added. 0s are ignored.", required=True, nargs='+') + # delete paper subparser delpaper_parser = functions.add_parser( @@ -102,7 +121,7 @@ def define_and_read_args() -> arg_namespace: ) delpaper_parser.set_defaults(func=delpaper) - delpaper_parser.add_argument('-k', '--key', type=str, help="Key for paper to be deleted.", required=True) + delpaper_parser.add_argument('-p', '--paperid', type=str, help="ID for paper to be deleted.", required=True) # get paper subparser getpapers_parser = functions.add_parser( @@ -112,8 +131,8 @@ def define_and_read_args() -> arg_namespace: getpapers_parser.set_defaults(func=getpapers) getpapers_parser.add_argument('-n', '--names', help="Get the names of the newspapers.", action='store_true') - getpapers_parser.add_argument('-d', '--days', help="Get the days the newspapers are delivered. Monday is the first day, and all seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't.", action='store_true') - getpapers_parser.add_argument('-p', '--prices', help="Get the daywise prices of the newspapers. Monday is the first day. Values must be separated by semicolons.", action='store_true') + getpapers_parser.add_argument('-d', '--delivered', help="Get the days the newspapers are delivered. All seven weekdays are required. A 'Y' means it is delivered, and an 'N' means it isn't.", action='store_true') + getpapers_parser.add_argument('-c', '--cost', help="Get the daywise prices of the newspapers. Values must be separated by semicolons.", action='store_true') # get undelivered logs subparser getlogs_parser = functions.add_parser( @@ -122,9 +141,12 @@ def define_and_read_args() -> arg_namespace: ) getlogs_parser.set_defaults(func=getlogs) + getlogs_parser.add_argument('-i', '--logid', type=int, help="ID for log to be retrieved.") + getlogs_parser.add_argument('-p', '--paperid', type=str, help="ID for paper.") getlogs_parser.add_argument('-m', '--month', type=int, help="Month. Must be between 1 and 12.") - getlogs_parser.add_argument('-y', '--year', type=int, help="Year. Must be between 1 and 9999.") - getlogs_parser.add_argument('-k', '--key', type=str, help="Key for paper.", required=True) + getlogs_parser.add_argument('-y', '--year', type=int, help="Year. Must be greater than 0.") + getlogs_parser.add_argument('-t' , '--timestamp', type=str, help="Timestamp. Must be in the format dd/mm/yyyy hh:mm:ss AM/PM.") + # update application subparser update_parser = functions.add_parser( @@ -138,370 +160,478 @@ def define_and_read_args() -> arg_namespace: return main_parser.parse_args() -## print out a coloured status message using Colorama def status_print(status: bool, message: str) -> None: + """ + print out a coloured status message using Colorama + - if the status is True, print in green (success) + - if the status is False, print in red (failure) + """ + if status: - print(f"{Fore.GREEN}{Style.BRIGHT}{message}{Style.RESET_ALL}\n") + print(f"{Fore.GREEN}", end="") else: - print(f"{Fore.RED}{Style.BRIGHT}{message}{Style.RESET_ALL}\n") + print(f"{Fore.RED}", end="") -## calculate the cost for a given month and year - # default to the previous month if no month and no year is given - # default to the current month if no month is given and year is given - # default to the current year if no year is given and month is given -def calculate(args: arg_namespace) -> None: + print(f"{Style.BRIGHT}{message}{Style.RESET_ALL}\n") - # deal with month and year - if args.month or args.year: - feedback = validate_month_and_year(args.month, args.year) +def calculate(args: ArgNamespace) -> None: + """calculate the cost for a given month and year + - default to the previous month if no month and no year is given + - default to the current month if no month is given and year is given + - default to the current year if no year is given and month is given""" - if not feedback[0]: - status_print(*feedback) - return + ## deal with month and year - if args.month: - month = args.month - - else: - month = datetime.now().month + # if either of them are given + if args.month or args.year: - if args.year: - year = args.year + # validate them + try: + npbc_core.validate_month_and_year(args.month, args.year) + + except npbc_exceptions.InvalidMonthYear: + status_print(False, "Invalid month and/or year.") + return - else: - year = datetime.now().year + # for each, if it is not given, set it to the current month and/or year + month = args.month or datetime.now().month + year = args.year or datetime.now().year + # if neither are given else: - previous_month = get_previous_month() + + # set them to the previous month and year + previous_month = npbc_core.get_previous_month() month = previous_month.month year = previous_month.year - # look for undelivered strings in the database - existing_strings = query_database( - generate_sql_query( - 'undelivered_strings', - columns=['paper_id', 'string'], - conditions={ - 'month': month, - 'year': year - } - ) - ) - - # associate undelivered strings with their paper_id - undelivered_strings: dict[int, str] = { - paper_id: undelivered_string - for paper_id, undelivered_string in existing_strings + # prepare a dictionary for undelivered strings + undelivered_strings = { + int(paper_id): [] + for paper_id, _, _, _, _ in npbc_core.get_papers() } - # calculate the cost for each paper, as well as the total cost - costs, total, undelivered_dates = calculate_cost_of_all_papers( - undelivered_strings, - month, - year - ) + # get the undelivered strings from the database + try: + raw_undelivered_strings = npbc_core.get_undelivered_strings(month=month, year=year) - # format the results - formatted = format_output(costs, total, month, year) + # add them to the dictionary + for _, paper_id, _, _, string in raw_undelivered_strings: + undelivered_strings[paper_id].append(string) - # unless the user specifies so, copy the results to the clipboard - if not args.nocopy: - copy_to_clipboard(formatted) + # ignore if none exist + except npbc_exceptions.StringNotExists: + pass + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return - formatted += '\nSummary copied to clipboard.' + try: + # calculate the cost for each paper + costs, total, undelivered_dates = npbc_core.calculate_cost_of_all_papers( + undelivered_strings, + month, + year + ) + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return + + # format the results + formatted = '\n'.join(npbc_core.format_output(costs, total, month, year)) # unless the user specifies so, log the results to the database if not args.nolog: - save_results(undelivered_dates, month, year) + try: + npbc_core.save_results(costs, undelivered_dates, month, year) + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return - formatted += '\nLog saved to file.' + formatted += '\n\nLog saved to file.' # print the results status_print(True, "Success!") - print(f"SUMMARY:\n{formatted}") + print(f"SUMMARY:\n\n{formatted}") -## add undelivered strings to the database - # default to the current month if no month and/or no year is given -def addudl(args: arg_namespace): +def addudl(args: ArgNamespace) -> None: + """add undelivered strings to the database + - default to the current month if no month and/or no year is given""" # validate the month and year - feedback = validate_month_and_year(args.month, args.year) + try: + npbc_core.validate_month_and_year(args.month, args.year) - if feedback[0]: + # if they are invalid, print an error message + except npbc_exceptions.InvalidMonthYear: + status_print(False, "Invalid month and/or year.") + return - # if no month is given, default to the current month - if args.month: - month = args.month + ## deal with month and year + # for any that are not given, set them to the current month and year + month = args.month or datetime.now().month + year = args.year or datetime.now().year - else: - month = datetime.now().month + # if the user either specifies a specific paper or specifies all papers + if args.paperid or args.all: - # if no year is given, default to the current year - if args.year: - year = args.year + # attempt to add the strings to the database + try: + print(f"{month=} {year=} {args.paperid=} {args.strings=}") + npbc_core.add_undelivered_string(month, year, args.paperid, *args.strings) - else: - year = datetime.now().year + # if the paper doesn't exist, print an error message + except npbc_exceptions.PaperNotExists: + status_print(False, f"Paper with ID {args.paperid} does not exist.") + return - # add the undelivered strings to the database - feedback = add_undelivered_string( - args.key, - str(args.undelivered).lower().strip(), - month, - year - ) + # if the string was invalid, print an error message + except npbc_exceptions.InvalidUndeliveredString: + status_print(False, "Invalid undelivered string(s).") + return + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return - status_print(*feedback) + # if no paper is specified, print an error message + else: + status_print(False, "No paper(s) specified.") + return + + status_print(True, "Success!") -## delete undelivered strings from the database -def deludl(args: arg_namespace) -> None: +def deludl(args: ArgNamespace) -> None: + """delete undelivered strings from the database""" # validate the month and year - feedback = validate_month_and_year(args.month, args.year) + try: + npbc_core.validate_month_and_year(args.month, args.year) - # delete the undelivered strings from the database - if feedback[0]: + # if they are invalid, print an error message + except npbc_exceptions.InvalidMonthYear: + status_print(False, "Invalid month and/or year.") + return - feedback = delete_undelivered_string( - args.key, - args.month, - args.year + # attempt to delete the strings from the database + try: + npbc_core.delete_undelivered_string( + month=args.month, + year=args.year, + paper_id=args.paperid, + string=args.string, + string_id=args.stringid ) - status_print(*feedback) + # if no parameters are given, print an error message + except npbc_exceptions.NoParameters: + status_print(False, "No parameters specified.") + return + + # if the string doesn't exist, print an error message + except npbc_exceptions.StringNotExists: + status_print(False, "String does not exist.") + return + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return + + status_print(True, "Success!") -## get undelivered strings from the database - # filter by whichever parameter the user provides. they as many as they want. - # available parameters: month, year, key, string -def getudl(args: arg_namespace) -> None: +def getudl(args: ArgNamespace) -> None: + """get undelivered strings from the database + filter by whichever parameter the user provides. they as many as they want. + available parameters: month, year, paper_id, string_id, string""" # validate the month and year - feedback = validate_month_and_year(args.month, args.year) + try: + npbc_core.validate_month_and_year(args.month, args.year) - if not feedback[0]: - status_print(*feedback) + # if they are invalid, print an error message + except npbc_exceptions.InvalidMonthYear: + status_print(False, "Invalid month and/or year.") return - - conditions = {} - if args.key: - conditions['paper_id'] = args.key + # attempt to get the strings from the database + try: + undelivered_strings = npbc_core.get_undelivered_strings( + month=args.month, + year=args.year, + paper_id=args.paperid, + string_id=args.stringid, + string=args.string + ) - if args.month: - conditions['month'] = args.month + # if the string doesn't exist, print an error message + except npbc_exceptions.StringNotExists: + status_print(False, "No strings found for the given parameters.") + return - if args.year: - conditions['year'] = args.year + status_print(True, "Success!") - if args.undelivered: - conditions['strings'] = str(args.undelivered).lower().strip() + ## format the results - if not validate_undelivered_string(conditions['strings']): - status_print(False, "Invalid undelivered string.") - return + # print the column headers + print(f"{Fore.YELLOW}string_id{Style.RESET_ALL} | {Fore.YELLOW}paper_id{Style.RESET_ALL} | {Fore.YELLOW}year{Style.RESET_ALL} | {Fore.YELLOW}month{Style.RESET_ALL} | {Fore.YELLOW}string{Style.RESET_ALL}") - # if the undelivered strings exist, fetch them - undelivered_strings = query_database( - generate_sql_query( - 'undelivered_strings', - conditions=conditions - ) - ) + # print the strings + for items in undelivered_strings: + print(', '.join([str(item) for item in items])) - # if there were undelivered strings, print them - if undelivered_strings: - status_print(True, 'Found undelivered strings.') - print(f"{Fore.YELLOW}entry_id{Style.RESET_ALL} | {Fore.YELLOW}year{Style.RESET_ALL} | {Fore.YELLOW}month{Style.RESET_ALL} | {Fore.YELLOW}paper_id{Style.RESET_ALL} | {Fore.YELLOW}string{Style.RESET_ALL}") - - for string in undelivered_strings: - print('|'.join([str(item) for item in string])) +def extract_delivery_from_user_input(input_delivery: str) -> list[bool]: + """convert the /[YN]{7}/ user input to a Boolean list""" - # otherwise, print that there were no undelivered strings - else: - status_print(False, 'No undelivered strings found.') + if not DELIVERY_MATCH_REGEX.match(input_delivery): + raise npbc_exceptions.InvalidInput("Invalid delivery days.") + + return [ + day == 'Y' + for day in input_delivery + ] -## edit the data for one paper -def editpaper(args: arg_namespace) -> None: - feedback = True, "" - days, costs = "", "" +def extract_costs_from_user_input(paper_id: int | None, delivery_data: list[bool] | None, *input_costs: float) -> Generator[float, None, None]: + """convert the user input to a float list""" - # validate the string for delivery days - if args.days: - days = str(args.days).lower().strip() + # filter the data to remove zeros + suspected_data = [ + cost + for cost in input_costs + if cost != 0 + ] - if not VALIDATE_REGEX['delivery'].match(days): - feedback = False, "Invalid delivery days." + # reverse it so that we can pop to get the first element first (FILO to FIFO) + suspected_data.reverse() - # validate the string for costs - if args.costs: - costs = str(args.costs).lower().strip() + # if the delivery data is given, use it + if delivery_data: - if not VALIDATE_REGEX['prices'].match(costs): - feedback = False, "Invalid prices." + # if the number of days the paper is delivered is not equal to the number of costs, raise an error + if (len(suspected_data) != delivery_data.count(True)): + raise npbc_exceptions.InvalidInput("Number of costs don't match number of days delivered.") - # if the string for delivery days and costs are valid, edit the paper - if feedback[0]: + # for each day, yield the cost if it is delivered or 0 if it is not + for day in delivery_data: + yield suspected_data.pop() if day else 0 - feedback = edit_existing_paper( - args.key, - args.name, - *extract_days_and_costs(days, costs) - ) + # if the delivery data is not given, but the paper ID is given, get the delivery data from the database + elif paper_id: + + # get the delivery data from the database, and filter for the paper ID + try: + raw_data = [paper for paper in npbc_core.get_papers() if paper[0] == int(paper_id)] + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return - status_print(*feedback) + raw_data.sort(key=lambda paper: paper[2]) + # extract the data from the database + delivered = [ + bool(delivered) + for _, _, _, delivered, _ in raw_data + ] -## add a new paper to the database -def addpaper(args: arg_namespace) -> None: - feedback = True, "" - days, costs = "", "" + # if the number of days the paper is delivered is not equal to the number of costs, raise an error + if len(suspected_data) != delivered.count(True): + raise npbc_exceptions.InvalidInput("Number of costs don't match number of days delivered.") + # for each day, yield the cost if it is delivered or 0 if it is not + for day in delivered: + yield suspected_data.pop() if day else 0 - # validate the string for delivery days - if args.days: - days = str(args.days).lower().strip() + else: + raise npbc_exceptions.InvalidInput("Neither delivery data nor paper ID given.") - if not VALIDATE_REGEX['delivery'].match(days): - feedback = False, "Invalid delivery days." - # validate the string for costs - if args.costs: - costs = str(args.costs).lower().strip() +def editpaper(args: ArgNamespace) -> None: + """edit a paper's information""" - if not VALIDATE_REGEX['prices'].match(costs): - feedback = False, "Invalid prices." - # if the string for delivery days and costs are valid, add the paper - if feedback[0]: + try: - feedback = add_new_paper( - args.name, - *extract_days_and_costs(days, costs) - ) + # attempt to get the delivery data. if it's not given, set it to None + delivery_data = extract_delivery_from_user_input(args.delivered) if args.delivered else None - status_print(*feedback) + # attempt to edit the paper. if costs are given, use them, else use None + npbc_core.edit_existing_paper( + paper_id=args.paperid, + name=args.name, + days_delivered=delivery_data, + days_cost=list(extract_costs_from_user_input(args.paperid, delivery_data, *args.costs)) if args.costs else None + ) + # if the paper doesn't exist, print an error message + except npbc_exceptions.PaperNotExists: + status_print(False, "Paper does not exist.") + return -## delete a paper from the database -def delpaper(args: arg_namespace) -> None: + # if some input is invalid, print an error message + except npbc_exceptions.InvalidInput as e: + status_print(False, f"Invalid input: {e}") + return + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return - # attempt to delete the paper - feedback = delete_existing_paper( - args.key - ) + status_print(True, "Success!") - status_print(*feedback) +def addpaper(args: ArgNamespace) -> None: + """add a new paper to the database""" -## get a list of all papers in the database - # filter by whichever parameter the user provides. they may use as many as they want (but keys are always printed) - # available parameters: name, days, costs - # the output is provided as a formatted table, printed to the standard output -def getpapers(args: arg_namespace) -> None: - headers = ['paper_id'] + try: + # attempt to get the delivery data + delivery_data = extract_delivery_from_user_input(args.delivered) - # fetch a list of all papers' IDs - papers_id_list = [ - paper_id - for paper_id, in query_database( - generate_sql_query( - 'papers', - columns=['paper_id'] - ) + # attempt to add the paper. + npbc_core.add_new_paper( + name=args.name, + days_delivered=delivery_data, + days_cost=list(extract_costs_from_user_input(None, delivery_data, *args.costs)) ) - ] - # initialize lists for the data - paper_name_list, paper_days_list, paper_costs_list = [], [], [] + # if the paper already exists, print an error message + except npbc_exceptions.PaperAlreadyExists: + status_print(False, "Paper already exists.") + return + + # if some input is invalid, print an error message + except npbc_exceptions.InvalidInput as e: + status_print(False, f"Invalid input: {e}") + return + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return - # sort the papers' IDs (for the sake of consistency) - papers_id_list.sort() + status_print(True, "Success!") - # if the user wants names, fetch that data and add it to the list - if args.names: - # first get a dictionary of {paper_id: paper_name} - papers_names = { - paper_id: paper_name - for paper_id, paper_name in query_database( - generate_sql_query( - 'papers', - columns=['paper_id', 'name'] - ) - ) - } +def delpaper(args: ArgNamespace) -> None: + """delete a paper from the database""" - # then use the sorted IDs list to create a sorted names list - paper_name_list = [ - papers_names[paper_id] - for paper_id in papers_id_list - ] + # attempt to delete the paper + try: + npbc_core.delete_existing_paper(args.paperid) - headers.append('name') + # if the paper doesn't exist, print an error message + except npbc_exceptions.PaperNotExists: + status_print(False, "Paper does not exist.") + return + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return - # if the user wants delivery days, fetch that data and add it to the list - if args.days: + status_print(True, "Success!") - # initialize a dictionary of {paper_id: {day_id: delivery}} - papers_days = { - paper_id: {} - for paper_id in papers_id_list - } - # then get the data for each paper - for paper_id, day_id, delivered in query_database( - generate_sql_query( - 'papers_days_delivered', - columns=['paper_id', 'day_id', 'delivered'] - ) - ): - papers_days[paper_id][day_id] = delivered - - # format the data so that it matches the regex pattern /^[YN]{7}$/, the same way the user must input this data - paper_days_list = [ - ''.join([ - 'Y' if int(papers_days[paper_id][day_id]) == 1 else 'N' - for day_id, _ in enumerate(WEEKDAY_NAMES) - ]) - for paper_id in papers_id_list - ] +def getpapers(args: ArgNamespace) -> None: + """get a list of all papers in the database + - filter by whichever parameter the user provides. they may use as many as they want (but keys are always printed) + - available parameters: name, days, costs + - the output is provided as a formatted table, printed to the standard output""" - headers.append('days') + # get the papers from the database + try: + raw_data = npbc_core.get_papers() - # if the user wants costs, fetch that data and add it to the list - if args.prices: + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return - # initialize a dictionary of {paper_id: {day_id: price}} - papers_costs = { + # initialize lists for column headers and paper IDs + headers = ['paper_id'] + ids = [] + + # extract paperr IDs + ids = list(set(paper[0] for paper in raw_data)) + ids.sort() + + # initialize lists for the data, based on the number of paper IDs + delivery = [None for _ in ids] + costs = [None for _ in ids] + names = [None for _ in ids] + + # if the user wants the name, add it to the headers and the data to the list + if args.names: + headers.append('name') + + names = list(set( + (paper_id, name) + for paper_id, name, _, _, _ in raw_data + )) + + # sort the names by paper ID and extract the names only + names.sort(key=lambda item: item[0]) + names = [name for _, name in names] + + # if the user wants the delivery data or the costs, get the data about days + if args.delivered or args.cost: + + # initialize a dictionary for the days of each paper + days = { paper_id: {} - for paper_id in papers_id_list + for paper_id in ids } - # then get the data for each paper - for paper_id, day_id, cost in query_database( - generate_sql_query( - 'papers_days_cost', - columns=['paper_id', 'day_id', 'cost'] - ) - ): - papers_costs[paper_id][day_id] = cost - - # format the data so that it matches the regex pattern /^[x](;[x]){6}$/, where /x/ is a number that may be either a floating point or an integer, the same way the user must input this data. - paper_costs_list = [ - ';'.join([ - str(papers_costs[paper_id][day_id]) - for day_id, _ in enumerate(WEEKDAY_NAMES) - ]) - for paper_id in papers_id_list - ] - - headers.append('costs') + # for each paper, add the days to the dictionary + for paper_id, _, day_id, _, _ in raw_data: + days[paper_id][day_id] = {} + + # for each paper, add the costs and delivery data to the dictionary + for paper_id, _, day_id, day_delivery, day_cost in raw_data: + days[paper_id][day_id]['delivery'] = day_delivery + days[paper_id][day_id]['cost'] = day_cost + + # if the user wants the delivery data, add it to the headers and the data to the list + if args.delivered: + headers.append('days') + + # convert the data to the /[YN]{7}/ format the user is used to + delivery = [ + ''.join([ + 'Y' if days[paper_id][day_id]['delivery'] else 'N' + for day_id, _ in enumerate(npbc_core.WEEKDAY_NAMES) + ]) + for paper_id in ids + ] + + # if the user wants the costs, add it to the headers and the data to the list + if args.cost: + headers.append('costs') + + # convert the data to the /x(;x){0,6}/ where x is a floating point number format the user is used to + costs = [ + ';'.join([ + str(days[paper_id][day_id]['cost']) + for day_id, _ in enumerate(npbc_core.WEEKDAY_NAMES) + if days[paper_id][day_id]['cost'] != 0 + ]) + for paper_id in ids + ] # print the headers print(' | '.join([ @@ -510,82 +640,87 @@ def getpapers(args: arg_namespace) -> None: ])) # print the data - for index, paper_id in enumerate(papers_id_list): - print(f"{paper_id}: ", end='') - - values = [] + for paper_id, name, delivered, cost in zip(ids, names, delivery, costs): + print(paper_id, end='') if args.names: - values.append(paper_name_list[index]) + print(f", {name}", end='') - if args.days: - values.append(paper_days_list[index]) + if args.delivered: + print(f", {delivered}", end='') - if args.prices: - values.append(paper_costs_list[index]) + if args.cost: + print(f", {cost}", end='') - print(', '.join(values)) + print() -## get a log of all deliveries for a paper - # the user may specify parameters to filter the output by. they may use as many as they want, or none - # available parameters: paper_id, month, year -def getlogs(args: arg_namespace) -> None: - - # validate the month and year - feedback = validate_month_and_year(args.month, args.year) +def getlogs(args: ArgNamespace) -> None: + """get a list of all logs in the database + - filter by whichever parameter the user provides. they may use as many as they want (but log IDs are always printed) + - available parameters: log_id, paper_id, month, year, timestamp + - will return both date logs and cost logs""" - if not feedback[0]: - status_print(*feedback) - return - - conditions = {} - - # if the user specified a particular paper, add it to the conditions - if args.key: - conditions['paper_id'] = args.key - - if args.month: - conditions['month'] = args.month - - if args.year: - conditions['year'] = args.year - - # fetch the data - undelivered_dates = query_database( - generate_sql_query( - 'undelivered_dates', - conditions=conditions + # attempt to get the logs from the database + try: + data = npbc_core.get_logged_data( + log_id = args.logid, + paper_id=args.paperid, + month=args.month, + year=args.year, + timestamp= datetime.strptime(args.timestamp, r'%d/%m/%Y %I:%M:%S %p') if args.timestamp else None ) - ) - # if data was found, print it - if undelivered_dates: - status_print(True, 'Success!') + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error. Please report this to the developer.\n{e}") + return + + # if there is a date format error, print an error message + except ValueError: + status_print(False, "Invalid date format. Please use the following format: dd/mm/yyyy hh:mm:ss AM/PM") + return - print(f"{Fore.YELLOW}entry_id{Style.RESET_ALL} | {Fore.YELLOW}year{Style.RESET_ALL} | {Fore.YELLOW}month{Style.RESET_ALL} | {Fore.YELLOW}paper_id{Style.RESET_ALL} | {Fore.YELLOW}dates{Style.RESET_ALL}") + # print column headers + print(' | '.join( + f"{Fore.YELLOW}{header}{Style.RESET_ALL}" + for header in ['log_id', 'paper_id', 'month', 'year', 'timestamp', 'date', 'cost'] + )) - for date in undelivered_dates: - print(', '.join(date)) + # print the data + for row in data: + print(', '.join(str(item) for item in row)) - # if no data was found, print an error message - else: - status_print(False, 'No results found.') +def update(args: ArgNamespace) -> None: + """update the application + - under normal operation, this function should never run + - if the update CLI argument is provided, this script will never run and the updater will be run instead""" -## update the application - # under normal operation, this function should never run - # if the update CLI argument is provided, this script will never run and the updater will be run instead -def update(args: arg_namespace) -> None: status_print(False, "Update failed.") -## run the application def main() -> None: - setup_and_connect_DB() - args = define_and_read_args() - args.func(args) + """main function + - initialize the database + - parses the command line arguments + - calls the appropriate function based on the arguments""" + + # attempt to initialize the database + try: + npbc_core.setup_and_connect_DB() + + # if there is a database error, print an error message + except sqlite3.DatabaseError as e: + status_print(False, f"Database error: {e}\nPlease report this to the developer.") + return + + # parse the command line arguments + parsed = define_and_read_args() + + # execute the appropriate function + parsed.func(parsed) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/npbc_core.py b/npbc_core.py index b6c1537..8e8be03 100644 --- a/npbc_core.py +++ b/npbc_core.py @@ -1,259 +1,268 @@ -from sqlite3 import connect +""" +provides the core functionality +- sets up and communicates with the DB +- adds, deletes, edits, or retrieves data from the DB (such as undelivered strings, paper data, logs) +- performs the main calculations +- handles validation and parsing of many values (such as undelivered strings) +""" + from calendar import day_name as weekday_names_iterable -from calendar import monthrange, monthcalendar -from datetime import date as date_type, datetime, timedelta +from calendar import monthcalendar, monthrange +from datetime import date as date_type +from datetime import datetime, timedelta +from os import environ from pathlib import Path -from re import compile as compile_regex +from sqlite3 import Connection, connect +from typing import Generator -## paths for the folder containing schema and database files - # during normal use, the DB will be in ~/.npbc (where ~ is the user's home directory) and the schema will be bundled with the executable - # during development, the DB and schema will both be in "data" +import npbc_exceptions +import npbc_regex -DATABASE_DIR = Path().home() / '.npbc' # normal use path -# DATABASE_DIR = Path('data') # development path +## paths for the folder containing schema and database files +# during normal use, the DB will be in ~/.npbc (where ~ is the user's home directory) and the schema will be bundled with the executable +# during development, the DB and schema will both be in "data" -DATABASE_PATH = DATABASE_DIR / 'npbc.db' +# default to PRODUCTION +DATABASE_DIR = Path.home() / '.npbc' +SCHEMA_PATH = Path(__file__).parent / 'schema.sql' -SCHEMA_PATH = Path(__file__).parent / 'schema.sql' # normal use path -# SCHEMA_PATH = DATABASE_DIR / 'schema.sql' # development path +# if in a development environment, set the paths to the data folder +if str(environ.get('NPBC_DEVELOPMENT')) == "1" or str(environ.get('CI')) == "true": + DATABASE_DIR = Path('data') + SCHEMA_PATH = Path('data') / 'schema.sql' +DATABASE_PATH = DATABASE_DIR / 'npbc.db' ## list constant for names of weekdays WEEKDAY_NAMES = list(weekday_names_iterable) +def setup_and_connect_DB() -> None: + """ensure DB exists and it's set up with the schema""" -## regex for validating user input -VALIDATE_REGEX = { - # match for a list of comma separated values. each value must be/contain digits, or letters, or hyphens. spaces are allowed between values and commas. any number of values are allowed, but at least one must be present. - 'CSVs': compile_regex(r'^[-\w]+( *, *[-\w]+)*( *,)?$'), + global DATABASE_DIR, DATABASE_PATH, SCHEMA_PATH - # match for a single number. must be one or two digits - 'number': compile_regex(r'^[\d]{1,2}?$'), + DATABASE_DIR.mkdir(parents=True, exist_ok=True) + DATABASE_PATH.touch(exist_ok=True) - # match for a range of numbers. each number must be one or two digits. numbers are separated by a hyphen. spaces are allowed between numbers and the hyphen. - 'range': compile_regex(r'^\d{1,2} *- *\d{1,2}$'), + with connect(DATABASE_PATH) as connection: + connection.executescript(SCHEMA_PATH.read_text()) - # match for weekday name. day must appear as "daynames" (example: "mondays"). all lowercase. - 'days': compile_regex(f"^{'|'.join([day_name.lower() + 's' for day_name in WEEKDAY_NAMES])}$"), + connection.close() - # match for nth weekday name. day must appear as "n-dayname" (example: "1-monday"). all lowercase. must be one digit. - 'n-day': compile_regex(f"^\\d *- *({'|'.join([day_name.lower() for day_name in WEEKDAY_NAMES])})$"), - # match for real values, delimited by semicolons. each value must be either an integer or a float with a decimal point. spaces are allowed between values and semicolons, and up to 7 (but at least 1) values are allowed. - 'costs': compile_regex(r'^\d+(\.\d+)?( *; *\d+(\.\d+)?){0,6} *;?$'), +def get_number_of_each_weekday(month: int, year: int) -> Generator[int, None, None]: + """generate a list of number of times each weekday occurs in a given month (return a generator) + - the list will be in the same order as WEEKDAY_NAMES (so the first day should be Monday)""" - # match for seven values, each of which must be a 'Y' or an 'N'. there are no delimiters. - 'delivery': compile_regex(r'^[YN]{7}$') -} + main_calendar = monthcalendar(year, month) + number_of_weeks = len(main_calendar) -## regex for splitting strings -SPLIT_REGEX = { - # split on hyphens. spaces are allowed between hyphens and values. - 'hyphen': compile_regex(r' *- *'), - - # split on semicolons. spaces are allowed between hyphens and values. - 'semicolon': compile_regex(r' *; *'), + for i, _ in enumerate(WEEKDAY_NAMES): + number_of_weekday: int = number_of_weeks - # split on commas. spaces are allowed between commas and values. - 'comma': compile_regex(r' *, *') -} + if main_calendar[0][i] == 0: + number_of_weekday -= 1 + + if main_calendar[-1][i] == 0: + number_of_weekday -= 1 + yield number_of_weekday -## ensure DB exists and it's set up with the schema -def setup_and_connect_DB() -> None: - DATABASE_DIR.mkdir(parents=True, exist_ok=True) - DATABASE_PATH.touch(exist_ok=True) - with connect(DATABASE_PATH) as connection: - connection.executescript(SCHEMA_PATH.read_text()) - connection.commit() +def validate_undelivered_string(*strings: str) -> None: + """validate a string that specifies when a given paper was not delivered + - first check to see that it meets the comma-separated requirements + - then check against each of the other acceptable patterns in the regex dictionary""" + # check that the string matches one of the acceptable patterns + for string in strings: + if string and not ( + npbc_regex.NUMBER_MATCH_REGEX.match(string) or + npbc_regex.RANGE_MATCH_REGEX.match(string) or + npbc_regex.DAYS_MATCH_REGEX.match(string) or + npbc_regex.N_DAY_MATCH_REGEX.match(string) or + npbc_regex.ALL_MATCH_REGEX.match(string) + ): -## generate a "SELECT" SQL query - # use params to specify columns to select, and "WHERE" conditions -def generate_sql_query(table_name: str, conditions: dict[str, int | str] | None = None, columns: list[str] | None = None) -> str: - sql_query = f"SELECT" - - if columns: - sql_query += f" {', '.join(columns)}" - - else: - sql_query += f" *" - - sql_query += f" FROM {table_name}" + raise npbc_exceptions.InvalidUndeliveredString(f'{string} is not a valid undelivered string.') - if conditions: - conditions_segment = ' AND '.join([ - f"{parameter_name} = {parameter_value}" - for parameter_name, parameter_value in conditions.items() - ]) - - sql_query += f" WHERE {conditions_segment}" + # if we get here, all strings passed the regex check - return f"{sql_query};" +def extract_number(string: str, month: int, year: int) -> date_type | None: + """if the date is simply a number, it's a single day. so we just identify that date""" + date = int(string) -## execute a "SELECT" SQL query and return the results -def query_database(query: str) -> list[tuple]: - with connect(DATABASE_PATH) as connection: - return connection.execute(query).fetchall() - - return [] + # if the date is valid for the given month + if date > 0 and date <= monthrange(year, month)[1]: + return date_type(year, month, date) -## generate a list of number of times each weekday occurs in a given month - # the list will be in the same order as WEEKDAY_NAMES (so the first day should be Monday) -def get_number_of_days_per_week(month: int, year: int) -> list[int]: - main_calendar = monthcalendar(year, month) - number_of_weeks = len(main_calendar) - number_of_weekdays = [] +def extract_range(string: str, month: int, year: int) -> Generator[date_type, None, None]: + """if the date is a range of numbers, it's a range of days. we identify all the dates in that range, bounds inclusive""" - for i, _ in enumerate(WEEKDAY_NAMES): - number_of_weekday = number_of_weeks + start, end = [int(date) for date in npbc_regex.HYPHEN_SPLIT_REGEX.split(string)] - if main_calendar[0][i] == 0: - number_of_weekday -= 1 - - if main_calendar[-1][i] == 0: - number_of_weekday -= 1 + # if the range is valid for the given month + if 0 < start <= end <= monthrange(year, month)[1]: + for date in range(start, end + 1): + yield date_type(year, month, date) - number_of_weekdays.append(number_of_weekday) - return number_of_weekdays +def extract_weekday(string: str, month: int, year: int) -> Generator[date_type, None, None]: + """if the date is the plural of a weekday name, we identify all dates in that month which are the given weekday""" + weekday = WEEKDAY_NAMES.index(string.capitalize().rstrip('s')) -## validate a string that specifies when a given paper was not delivered - # first check to see that it meets the comma-separated requirements - # then check against each of the other acceptable patterns in the regex dictionary -def validate_undelivered_string(string: str) -> bool: - if VALIDATE_REGEX['CSVs'].match(string): - - for section in SPLIT_REGEX['comma'].split(string.rstrip(',')): - section_validity = False + for day in range(1, monthrange(year, month)[1] + 1): + if date_type(year, month, day).weekday() == weekday: + yield date_type(year, month, day) - for pattern, regex in VALIDATE_REGEX.items(): - if (not section_validity) and (pattern not in ["CSVs", "costs", "delivery"]) and (regex.match(section)): - section_validity = True - if not section_validity: - return False +def extract_nth_weekday(string: str, month: int, year: int) -> date_type | None: + """if the date is a number and a weekday name (singular), we identify the date that is the nth occurrence of the given weekday in the month""" + + n, weekday_name = npbc_regex.HYPHEN_SPLIT_REGEX.split(string) + + n = int(n) + + # if the day is valid for the given month + if n > 0 and n <= list(get_number_of_each_weekday(month, year))[WEEKDAY_NAMES.index(weekday_name.capitalize())]: - return True - - return False + # record the "day_id" corresponding to the given weekday name + weekday = WEEKDAY_NAMES.index(weekday_name.capitalize()) + + # store all dates when the given weekday occurs in the given month + valid_dates = [ + date_type(year, month, day) + for day in range(1, monthrange(year, month)[1] + 1) + if date_type(year, month, day).weekday() == weekday + ] + # return the date that is the nth occurrence of the given weekday in the month + return valid_dates[n - 1] -## parse a string that specifies when a given paper was not delivered - # each CSV section states some set of dates - # this function will return a set of dates that uniquely identifies each date mentioned across all the CSVs -def parse_undelivered_string(string: str, month: int, year: int) -> set[date_type]: - dates = set() - for section in SPLIT_REGEX['comma'].split(string.rstrip(',')): +def extract_all(month: int, year: int) -> Generator[date_type, None, None]: + """if the text is "all", we identify all the dates in the month""" - # if the date is simply a number, it's a single day. so we just identify that date - if VALIDATE_REGEX['number'].match(section): - date = int(section) + for day in range(1, monthrange(year, month)[1] + 1): + yield date_type(year, month, day) - if date > 0 and date <= monthrange(year, month)[1]: - dates.add(date_type(year, month, date)) - # if the date is a range of numbers, it's a range of days. we identify all the dates in that range, bounds inclusive - elif VALIDATE_REGEX['range'].match(section): - start, end = [int(date) for date in SPLIT_REGEX['hyphen'].split(section)] +def parse_undelivered_string(month: int, year: int, string: str) -> set[date_type]: + """parse a section of the strings + - each section is a string that specifies a set of dates + - this function will return a set of dates that uniquely identifies each date mentioned across the string""" - if (0 < start) and (start <= end) and (end <= monthrange(year, month)[1]): - dates.update( - date_type(year, month, day) - for day in range(start, end + 1) - ) + # initialize the set of dates + dates = set() - # if the date is the plural of a weekday name, we identify all dates in that month which are the given weekday - elif VALIDATE_REGEX['days'].match(section): - weekday = WEEKDAY_NAMES.index(section.capitalize().rstrip('s')) + # check for each of the patterns + if npbc_regex.NUMBER_MATCH_REGEX.match(string): + number_date = extract_number(string, month, year) - dates.update( - date_type(year, month, day) - for day in range(1, monthrange(year, month)[1] + 1) - if date_type(year, month, day).weekday() == weekday - ) + if number_date: + dates.add(number_date) - # if the date is a number and a weekday name (singular), we identify the date that is the nth occurrence of the given weekday in the month - elif VALIDATE_REGEX['n-day'].match(section): - n, weekday = SPLIT_REGEX['hyphen'].split(section) + elif npbc_regex.RANGE_MATCH_REGEX.match(string): + dates.update(extract_range(string, month, year)) - n = int(n) + elif npbc_regex.DAYS_MATCH_REGEX.match(string): + dates.update(extract_weekday(string, month, year)) - if n > 0 and n <= get_number_of_days_per_week(month, year)[WEEKDAY_NAMES.index(weekday.capitalize())]: - weekday = WEEKDAY_NAMES.index(weekday.capitalize()) + elif npbc_regex.N_DAY_MATCH_REGEX.match(string): + n_day_date = extract_nth_weekday(string, month, year) - valid_dates = [ - date_type(year, month, day) - for day in range(1, monthrange(year, month)[1] + 1) - if date_type(year, month, day).weekday() == weekday - ] + if n_day_date: + dates.add(n_day_date) - dates.add(valid_dates[n - 1]) + elif npbc_regex.ALL_MATCH_REGEX.match(string): + dates.update(extract_all(month, year)) - # bug report :) - else: - print("Congratulations! You broke the program!") - print("You managed to write a string that the program considers valid, but isn't actually.") - print("Please report it to the developer.") - print(f"\nThe string you wrote was: {string}") - print("This data has not been counted.") + else: + raise npbc_exceptions.InvalidUndeliveredString(f'{string} is not a valid undelivered string.') return dates + +def parse_undelivered_strings(month: int, year: int, *strings: str) -> set[date_type]: + """parse a string that specifies when a given paper was not delivered + - each section states some set of dates + - this function will return a set of dates that uniquely identifies each date mentioned across all the strings""" + + # initialize the set of dates + dates = set() -## get the cost and delivery data for a given paper from the DB - # each of them are converted to a dictionary, whose index is the day_id - # the two dictionaries are then returned as a tuple -def get_cost_and_delivery_data(paper_id: int) -> tuple[dict[int, float], dict[int, bool]]: - cost_query = generate_sql_query( - 'papers_days_cost', - columns=['day_id', 'cost'], - conditions={'paper_id': paper_id} - ) + # check for each of the patterns + for string in strings: + try: + dates.update(parse_undelivered_string(month, year, string)) + + except npbc_exceptions.InvalidUndeliveredString: + print( + f"""Congratulations! You broke the program! + You managed to write a string that the program considers valid, but isn't actually. + Please report it to the developer. + \nThe string you wrote was: {string} + This data has not been counted.""" + ) - delivery_query = generate_sql_query( - 'papers_days_delivered', - columns=['day_id', 'delivered'], - conditions={'paper_id': paper_id} - ) + return dates - with connect(DATABASE_PATH) as connection: - cost_tuple = connection.execute(cost_query).fetchall() - delivery_tuple = connection.execute(delivery_query).fetchall() +def get_cost_and_delivery_data(paper_id: int, connection: Connection) -> tuple[dict[int, float], dict[int, bool]]: + """get the cost and delivery data for a given paper from the DB + - each of them are converted to a dictionary, whose index is the day_id + - the two dictionaries are then returned as a tuple""" + + query = """ + SELECT papers_days.day_id, papers_days_delivered.delivered, papers_days_cost.cost + FROM papers_days + INNER JOIN papers_days_delivered + ON papers_days.paper_day_id = papers_days_delivered.paper_day_id + INNER JOIN papers_days_cost + ON papers_days.paper_day_id = papers_days_cost.paper_day_id + WHERE papers_days.paper_id = ? + """ + + retrieved_data = connection.execute(query, (paper_id, )).fetchall() + + # extract the cost data for each day_id cost_dict = { - day_id: cost - for day_id, cost in cost_tuple # type: ignore + row[0]: row[2] + for row in retrieved_data } - delivery_dict = { - day_id: delivery - for day_id, delivery in delivery_tuple # type: ignore + # extract the delivery data for each day_id + delivered_dict = { + row[0]: bool(row[1]) + for row in retrieved_data } - return cost_dict, delivery_dict + return cost_dict, delivered_dict -## calculate the cost of one paper for the full month - # any dates when it was not delivered will be removed -def calculate_cost_of_one_paper(number_of_days_per_week: list[int], undelivered_dates: set[date_type], cost_and_delivered_data: tuple[dict[int, float], dict[int, bool]]) -> float: +def calculate_cost_of_one_paper( + number_of_each_weekday: list[int], + undelivered_dates: set[date_type], + cost_and_delivered_data: tuple[dict[int, float], dict[int, bool]] + ) -> float: + """calculate the cost of one paper for the full month + - any dates when it was not delivered will be removed""" + cost_data, delivered_data = cost_and_delivered_data # initialize counters corresponding to each weekday when the paper was not delivered - number_of_days_per_week_not_received = [0] * len(number_of_days_per_week) + number_of_days_per_weekday_not_received = [0] * len(number_of_each_weekday) # for each date that the paper was not delivered, we increment the counter for the corresponding weekday for date in undelivered_dates: - number_of_days_per_week_not_received[date.weekday()] += 1 + number_of_days_per_weekday_not_received[date.weekday()] += 1 # calculate the total number of each weekday the paper was delivered (if it is supposed to be delivered) number_of_days_delivered = [ - number_of_days_per_week[day_id] - number_of_days_per_week_not_received[day_id] if delivered else 0 + number_of_each_weekday[day_id] - number_of_days_per_weekday_not_received[day_id] if delivered else 0 for day_id, delivered in delivered_data.items() ] @@ -264,40 +273,47 @@ def calculate_cost_of_one_paper(number_of_days_per_week: list[int], undelivered_ ) -## calculate the cost of all papers for the full month - # return data about the cost of each paper, the total cost, and dates when each paper was not delivered -def calculate_cost_of_all_papers(undelivered_strings: dict[int, str], month: int, year: int) -> tuple[dict[int, float], float, dict[int, set[date_type]]]: - NUMBER_OF_DAYS_PER_WEEK = get_number_of_days_per_week(month, year) +def calculate_cost_of_all_papers(undelivered_strings: dict[int, list[str]], month: int, year: int) -> tuple[ + dict[int, float], + float, + dict[int, set[date_type]] +]: + """calculate the cost of all papers for the full month + - return data about the cost of each paper, the total cost, and dates when each paper was not delivered""" + + global DATABASE_PATH + NUMBER_OF_EACH_WEEKDAY = list(get_number_of_each_weekday(month, year)) + cost_and_delivery_data = [] # get the IDs of papers that exist with connect(DATABASE_PATH) as connection: - papers = connection.execute( - generate_sql_query( - 'papers', - columns=['paper_id'] - ) - ).fetchall() + papers = connection.execute("SELECT paper_id FROM papers;").fetchall() - # get the data about cost and delivery for each paper - cost_and_delivery_data = [ - get_cost_and_delivery_data(paper_id) - for paper_id, in papers # type: ignore - ] + # get the data about cost and delivery for each paper + cost_and_delivery_data = [ + get_cost_and_delivery_data(paper_id, connection) + for paper_id, in papers # type: ignore + ] + + connection.close() # initialize a "blank" dictionary that will eventually contain any dates when a paper was not delivered undelivered_dates: dict[int, set[date_type]] = { - paper_id: {} + paper_id: set() for paper_id, in papers # type: ignore } + # calculate the undelivered dates for each paper - for paper_id, undelivered_string in undelivered_strings.items(): # type: ignore - undelivered_dates[paper_id] = parse_undelivered_string(undelivered_string, month, year) + for paper_id, strings in undelivered_strings.items(): + undelivered_dates[paper_id].update( + parse_undelivered_strings(month, year, *strings) + ) # calculate the cost of each paper costs = { paper_id: calculate_cost_of_one_paper( - NUMBER_OF_DAYS_PER_WEEK, + NUMBER_OF_EACH_WEEKDAY, undelivered_dates[paper_id], cost_and_delivery_data[index] ) @@ -310,324 +326,521 @@ def calculate_cost_of_all_papers(undelivered_strings: dict[int, str], month: int return costs, total, undelivered_dates -## save the results of undelivered dates to the DB - # save the dates any paper was not delivered -def save_results(undelivered_dates: dict[int, set[date_type]], month: int, year: int) -> None: - TIMESTAMP = datetime.now().strftime(r'%d/%m/%Y %I:%M:%S %p') +def save_results( + costs: dict[int, float], + undelivered_dates: dict[int, set[date_type]], + month: int, + year: int, + custom_timestamp: datetime | None = None +) -> None: + """save the results of undelivered dates to the DB + - save the dates any paper was not delivered + - save the final cost of each paper""" + + global DATABASE_PATH + timestamp = (custom_timestamp if custom_timestamp else datetime.now()).strftime(r'%d/%m/%Y %I:%M:%S %p') with connect(DATABASE_PATH) as connection: - for paper_id, undelivered_date_instances in undelivered_dates.items(): + + # create log entries for each paper + log_ids = { + paper_id: connection.execute( + """ + INSERT INTO logs (paper_id, month, year, timestamp) + VALUES (?, ?, ?, ?) + RETURNING logs.log_id; + """, + (paper_id, month, year, timestamp) + ).fetchone()[0] + for paper_id in costs.keys() + } + + # create cost entries for each paper + for paper_id, log_id in log_ids.items(): connection.execute( - "INSERT INTO undelivered_dates (timestamp, month, year, paper_id, dates) VALUES (?, ?, ?, ?, ?);", - ( - TIMESTAMP, - month, - year, - paper_id, - ','.join([ - undelivered_date_instance.strftime(r'%d') - for undelivered_date_instance in undelivered_date_instances - ]) - ) + """ + INSERT INTO cost_logs (log_id, cost) + VALUES (?, ?); + """, + (log_id, costs[paper_id]) ) + # create undelivered date entries for each paper + for paper_id, dates in undelivered_dates.items(): + for date in dates: + connection.execute( + """ + INSERT INTO undelivered_dates_logs (log_id, date_not_delivered) + VALUES (?, ?); + """, + (log_ids[paper_id], date.strftime("%Y-%m-%d")) + ) -## format the output of calculating the cost of all papers -def format_output(costs: dict[int, float], total: float, month: int, year: int) -> str: - papers = { - paper_id: name - for paper_id, name in query_database( - generate_sql_query('papers') - ) - } - + connection.close() - format_string = f"For {date_type(year=year, month=month, day=1).strftime(r'%B %Y')}\n\n" - format_string += f"*TOTAL*: {total}\n" - format_string += '\n'.join([ - f"{papers[paper_id]}: {cost}" # type: ignore - for paper_id, cost in costs.items() - ]) +def format_output(costs: dict[int, float], total: float, month: int, year: int) -> Generator[str, None, None]: + """format the output of calculating the cost of all papers""" + + global DATABASE_PATH - return f"{format_string}\n" + # output the name of the month for which the total cost was calculated + yield f"For {date_type(year=year, month=month, day=1).strftime(r'%B %Y')},\n" + # output the total cost of all papers + yield f"*TOTAL*: {total}" -## add a new paper - # do not allow if the paper already exists -def add_new_paper(name: str, days_delivered: list[bool], days_cost: list[float]) -> tuple[bool, str]: + # output the cost of each paper with its name with connect(DATABASE_PATH) as connection: + papers = { + row[0]: row[1] + for row in connection.execute("SELECT paper_id, name FROM papers;").fetchall() + } + + for paper_id, cost in costs.items(): + yield f"{papers[paper_id]}: {cost}" + + connection.close() - # get the names of all papers that already exist - paper = connection.execute( - generate_sql_query('papers', columns=['name'], conditions={'name': f"\"{name}\""}) - ).fetchall() - # if the proposed paper already exists, return an error message - if paper: - return False, "Paper already exists. Please try editing the paper instead." +def add_new_paper(name: str, days_delivered: list[bool], days_cost: list[float]) -> None: + """add a new paper + - do not allow if the paper already exists""" + + global DATABASE_PATH + + with connect(DATABASE_PATH) as connection: - # otherwise, add the paper name to the database - connection.execute( - "INSERT INTO papers (name) VALUES (?);", - (name, ) - ) + # check if the paper already exists + if connection.execute( + "SELECT EXISTS (SELECT 1 FROM papers WHERE name = ?);", + (name,)).fetchone()[0]: + raise npbc_exceptions.PaperAlreadyExists(f"Paper \"{name}\" already exists.") - # get the ID of the paper that was just added + # insert the paper paper_id = connection.execute( - "SELECT paper_id FROM papers WHERE name = ?;", - (name, ) + "INSERT INTO papers (name) VALUES (?) RETURNING papers.paper_id;", + (name,) ).fetchone()[0] - # add the cost and delivery data for the paper - for day_id, (cost, delivered) in enumerate(zip(days_cost, days_delivered)): + # create days for the paper + paper_days = { + day_id: connection.execute( + "INSERT INTO papers_days (paper_id, day_id) VALUES (?, ?) RETURNING papers_days.paper_day_id;", + (paper_id, day_id) + ).fetchone()[0] + for day_id, _ in enumerate(days_delivered) + } + + # create cost entries for each day + for day_id, cost in enumerate(days_cost): connection.execute( - "INSERT INTO papers_days_cost (paper_id, day_id, cost) VALUES (?, ?, ?);", - (paper_id, day_id, cost) + "INSERT INTO papers_days_cost (paper_day_id, cost) VALUES (?, ?);", + (paper_days[day_id], cost) ) + + # create delivered entries for each day + for day_id, delivered in enumerate(days_delivered): connection.execute( - "INSERT INTO papers_days_delivered (paper_id, day_id, delivered) VALUES (?, ?, ?);", - (paper_id, day_id, delivered) + "INSERT INTO papers_days_delivered (paper_day_id, delivered) VALUES (?, ?);", + (paper_days[day_id], delivered) ) - - connection.commit() - - return True, f"Paper {name} added." - return False, "Something went wrong." + connection.close() -## edit an existing paper - # do not allow if the paper does not exist -def edit_existing_paper(paper_id: int, name: str | None = None, days_delivered: list[bool] | None = None, days_cost: list[float] | None = None) -> tuple[bool, str]: - with connect(DATABASE_PATH) as connection: +def edit_existing_paper( + paper_id: int, + name: str | None = None, + days_delivered: list[bool] | None = None, + days_cost: list[float] | None = None +) -> None: + """edit an existing paper + do not allow if the paper does not exist""" - # get the IDs of all papers that already exist - paper = connection.execute( - generate_sql_query('papers', columns=['paper_id'], conditions={'paper_id': paper_id}) - ).fetchone() + global DATABASE_PATH - # if the proposed paper does not exist, return an error message - if not paper: - return False, f"Paper {paper_id} does not exist. Please try adding it instead." + with connect(DATABASE_PATH) as connection: + + # check if the paper exists + if not connection.execute( + "SELECT EXISTS (SELECT 1 FROM papers WHERE paper_id = ?);", + (paper_id,)).fetchone()[0]: + raise npbc_exceptions.PaperNotExists(f"Paper with ID {paper_id} does not exist.") - # if a name is proposed, update the name of the paper + # update the paper name if name is not None: connection.execute( "UPDATE papers SET name = ? WHERE paper_id = ?;", (name, paper_id) ) - - # if delivery data is proposed, update the delivery data of the paper - if days_delivered is not None: - for day_id, delivered in enumerate(days_delivered): - connection.execute( - "UPDATE papers_days_delivered SET delivered = ? WHERE paper_id = ? AND day_id = ?;", - (delivered, paper_id, day_id) - ) - # if cost data is proposed, update the cost data of the paper - if days_cost is not None: - for day_id, cost in enumerate(days_cost): - connection.execute( - "UPDATE papers_days_cost SET cost = ? WHERE paper_id = ? AND day_id = ?;", - (cost, paper_id, day_id) + # get the days for the paper + if (days_delivered is not None) or (days_cost is not None): + paper_days = { + row[0]: row[1] + for row in connection.execute( + "SELECT day_id, paper_day_id FROM papers_days WHERE paper_id = ?;", + (paper_id,) ) + } + + # update the delivered data for the paper + if days_delivered is not None: + for day_id, delivered in enumerate(days_delivered): + connection.execute( + "UPDATE papers_days_delivered SET delivered = ? WHERE paper_day_id = ?;", + (delivered, paper_days[day_id]) + ) - connection.commit() - - return True, f"Paper {paper_id} edited." + # update the days for the paper + if days_cost is not None: + for day_id, cost in enumerate(days_cost): + connection.execute( + "UPDATE papers_days_cost SET cost = ? WHERE paper_day_id = ?;", + (cost, paper_days[day_id]) + ) - return False, "Something went wrong." + connection.close() -## delete an existing paper - # do not allow if the paper does not exist -def delete_existing_paper(paper_id: int) -> tuple[bool, str]: - with connect(DATABASE_PATH) as connection: +def delete_existing_paper(paper_id: int) -> None: + """delete an existing paper + - do not allow if the paper does not exist""" - # get the IDs of all papers that already exist - paper = connection.execute( - generate_sql_query('papers', columns=['paper_id'], conditions={'paper_id': paper_id}) - ).fetchone() + global DATABASE_PATH - # if the proposed paper does not exist, return an error message - if not paper: - return False, f"Paper {paper_id} does not exist. Please try adding it instead." + with connect(DATABASE_PATH) as connection: + + # check if the paper exists + if not connection.execute( + "SELECT EXISTS (SELECT 1 FROM papers WHERE paper_id = ?);", + (paper_id,)).fetchone()[0]: + raise npbc_exceptions.PaperNotExists(f"Paper with ID {paper_id} does not exist.") - # delete the paper from the names table + # delete the paper connection.execute( "DELETE FROM papers WHERE paper_id = ?;", - (paper_id, ) + (paper_id,) ) - # delete the paper from the delivery data table - connection.execute( - "DELETE FROM papers_days_delivered WHERE paper_id = ?;", - (paper_id, ) - ) + # get the days for the paper + paper_days = [ + row[0] + for row in connection.execute("SELECT paper_day_id FROM papers_days WHERE paper_id = ?;", (paper_id,)) + ] - # delete the paper from the cost data table + # delete the costs and delivery data for the paper + for paper_day_id in paper_days: + connection.execute( + "DELETE FROM papers_days_cost WHERE paper_day_id = ?;", + (paper_day_id,) + ) + + connection.execute( + "DELETE FROM papers_days_delivered WHERE paper_day_id = ?;", + (paper_day_id,) + ) + + # delete the days for the paper connection.execute( - "DELETE FROM papers_days_cost WHERE paper_id = ?;", - (paper_id, ) + "DELETE FROM papers_days WHERE paper_id = ?;", + (paper_id,) ) - connection.commit() + connection.close() - return True, f"Paper {paper_id} deleted." - return False, "Something went wrong." +def add_undelivered_string(month: int, year: int, paper_id: int | None = None, *undelivered_strings: str) -> None: + """record strings for date(s) paper(s) were not delivered + - if no paper ID is specified, all papers are assumed""" + global DATABASE_PATH + + # validate the strings + validate_undelivered_string(*undelivered_strings) + + # if a paper ID is given + if paper_id: + + # check that specified paper exists in the database + with connect(DATABASE_PATH) as connection: + if not connection.execute( + "SELECT EXISTS (SELECT 1 FROM papers WHERE paper_id = ?);", + (paper_id,)).fetchone()[0]: + raise npbc_exceptions.PaperNotExists(f"Paper with ID {paper_id} does not exist.") + + # add the string(s) + params = [ + (month, year, paper_id, string) + for string in undelivered_strings + ] + + connection.executemany("INSERT INTO undelivered_strings (month, year, paper_id, string) VALUES (?, ?, ?, ?);", params) + + connection.close() + + # if no paper ID is given + else: + + # get the IDs of all papers + with connect(DATABASE_PATH) as connection: + paper_ids = [ + row[0] + for row in connection.execute( + "SELECT paper_id FROM papers;" + ) + ] + + # add the string(s) + params = [ + (month, year, paper_id, string) + for paper_id in paper_ids + for string in undelivered_strings + ] + + connection.executemany("INSERT INTO undelivered_strings (month, year, paper_id, string) VALUES (?, ?, ?, ?);", params) + + connection.close() + + +def delete_undelivered_string( + string_id: int | None = None, + string: str | None = None, + paper_id: int | None = None, + month: int | None = None, + year: int | None = None +) -> None: + """delete an existing undelivered string + - do not allow if the string does not exist""" + + global DATABASE_PATH + + # initialize parameters for the WHERE clause of the SQL query + parameters = [] + values = [] + + # check each parameter and add it to the WHERE clause if it is given + if string_id: + parameters.append("string_id") + values.append(string_id) + + if string: + parameters.append("string") + values.append(string) + + if paper_id: + parameters.append("paper_id") + values.append(paper_id) + + if month: + parameters.append("month") + values.append(month) + + if year: + parameters.append("year") + values.append(year) + + # if no parameters are given, raise an error + if not parameters: + raise npbc_exceptions.NoParameters("No parameters given.") -## record strings for date(s) paper(s) were not delivered -def add_undelivered_string(paper_id: int, undelivered_string: str, month: int, year: int) -> tuple[bool, str]: - - # if the string is not valid, return an error message - if not validate_undelivered_string(undelivered_string): - return False, f"Invalid undelivered string." - with connect(DATABASE_PATH) as connection: - # check if given paper exists - paper = connection.execute( - generate_sql_query( - 'papers', - columns=['paper_id'], - conditions={'paper_id': paper_id} - ) - ).fetchone() - - # if the paper does not exist, return an error message - if not paper: - return False, f"Paper {paper_id} does not exist. Please try adding it instead." - - # check if a string with the same month and year, for the same paper, already exists - existing_string = connection.execute( - generate_sql_query( - 'undelivered_strings', - columns=['string'], - conditions={ - 'paper_id': paper_id, - 'month': month, - 'year': year - } - ) - ).fetchone() - # if a string with the same month and year, for the same paper, already exists, concatenate the new string to it - if existing_string: - new_string = f"{existing_string[0]},{undelivered_string}" + # check if the string exists + check_query = "SELECT EXISTS (SELECT 1 FROM undelivered_strings" - connection.execute( - "UPDATE undelivered_strings SET string = ? WHERE paper_id = ? AND month = ? AND year = ?;", - (new_string, paper_id, month, year) - ) + conditions = ' AND '.join( + f"{parameter} = ?" + for parameter in parameters + ) + + if (1,) not in connection.execute(f"{check_query} WHERE {conditions});", values).fetchall(): + raise npbc_exceptions.StringNotExists("String with given parameters does not exist.") + + # if the string did exist, delete it + delete_query = "DELETE FROM undelivered_strings" + + connection.execute(f"{delete_query} WHERE {conditions};", values) + + connection.close() - # otherwise, add the new string to the database - else: - connection.execute( - "INSERT INTO undelivered_strings (string, paper_id, month, year) VALUES (?, ?, ?, ?);", - (undelivered_string, paper_id, month, year) - ) - connection.commit() +def get_papers() -> list[tuple[int, str, int, int, float]]: + """get all papers + - returns a list of tuples containing the following fields: + paper_id, paper_name, day_id, paper_delivered, paper_cost""" - return True, f"Undelivered string added." + global DATABASE_PATH + raw_data = [] + + query = """ + SELECT papers.paper_id, papers.name, papers_days.day_id, papers_days_delivered.delivered, papers_days_cost.cost + FROM papers + INNER JOIN papers_days ON papers.paper_id = papers_days.paper_id + INNER JOIN papers_days_delivered ON papers_days.paper_day_id = papers_days_delivered.paper_day_id + INNER JOIN papers_days_cost ON papers_days.paper_day_id = papers_days_cost.paper_day_id; + """ -## delete an existing undelivered string - # do not allow if the string does not exist -def delete_undelivered_string(paper_id: int, month: int, year: int) -> tuple[bool, str]: with connect(DATABASE_PATH) as connection: - - # check if a string with the same month and year, for the same paper, exists - existing_string = connection.execute( - generate_sql_query( - 'undelivered_strings', - columns=['string'], - conditions={ - 'paper_id': paper_id, - 'month': month, - 'year': year - } - ) - ).fetchone() + raw_data = connection.execute(query).fetchall() - # if it does, delete it - if existing_string: - connection.execute( - "DELETE FROM undelivered_strings WHERE paper_id = ? AND month = ? AND year = ?;", - (paper_id, month, year) - ) + connection.close() - connection.commit() + return raw_data - return True, f"Undelivered string deleted." - # if the string does not exist, return an error message - return False, f"Undelivered string does not exist." +def get_undelivered_strings( + string_id: int | None = None, + month: int | None = None, + year: int | None = None, + paper_id: int | None = None, + string: str | None = None +) -> list[tuple[int, int, int, int, str]]: + """get undelivered strings + - the user may specify as many as they want parameters + - available parameters: string_id, month, year, paper_id, string + - returns a list of tuples containing the following fields: + string_id, paper_id, year, month, string""" - return False, "Something went wrong." + global DATABASE_PATH + # initialize parameters for the WHERE clause of the SQL query + parameters = [] + values = [] + data = [] -## get the previous month, by looking at 1 day before the first day of the current month (duh) -def get_previous_month() -> date_type: - return (datetime.today().replace(day=1) - timedelta(days=1)).replace(day=1) + # check each parameter and add it to the WHERE clause if it is given + if string_id: + parameters.append("string_id") + values.append(string_id) + if month: + parameters.append("month") + values.append(month) -## extract delivery days and costs from user input -def extract_days_and_costs(days_delivered: str | None, prices: str | None, paper_id: int | None = None) -> tuple[list[bool], list[float]]: - days = [] - costs = [] + if year: + parameters.append("year") + values.append(year) - # if the user has provided delivery days, extract them - if days_delivered is not None: - days = [ - bool(int(day == 'Y')) for day in str(days_delivered).upper() - ] + if paper_id: + parameters.append("paper_id") + values.append(paper_id) + + if string: + parameters.append("string") + values.append(string) + + + with connect(DATABASE_PATH) as connection: + + # generate the SQL query + main_query = "SELECT string_id, paper_id, year, month, string FROM undelivered_strings" + + if not parameters: + query = f"{main_query};" + + else: + conditions = ' AND '.join( + f"{parameter} = ?" + for parameter in parameters + ) + + query = f"{main_query} WHERE {conditions};" + + data = connection.execute(query, values).fetchall() + connection.close() + + # if no data was found, raise an error + if not data: + raise npbc_exceptions.StringNotExists("String with given parameters does not exist.") + + return data + + +def get_logged_data( + paper_id: int | None = None, + log_id: int | None = None, + month: int | None = None, + year: int | None = None, + timestamp: date_type | None = None +): + """get logged data + - the user may specify as parameters many as they want + - available parameters: paper_id, log_id, month, year, timestamp + - returns: a list of tuples containing the following fields: + log_id, paper_id, month, year, timestamp, date, cost.""" + + global DATABASE_PATH + + # initialize parameters for the WHERE clause of the SQL query + data = [] + parameters = [] + values = () + + # check each parameter and add it to the WHERE clause if it is given + if paper_id: + parameters.append("paper_id") + values += (paper_id,) + + if log_id: + parameters.append("log_id") + values += (log_id,) + + if month: + parameters.append("month") + values += (month,) + + if year: + parameters.append("year") + values += (year,) + + if timestamp: + parameters.append("timestamp") + values += (timestamp.strftime(r'%d/%m/%Y %I:%M:%S %p'),) + + # generate the SQL query + columns_only_query = """ + SELECT logs.log_id, logs.paper_id, logs.month, logs.year, logs.timestamp, undelivered_dates_logs.date_not_delivered, cost_logs.cost + FROM logs + INNER JOIN undelivered_dates_logs ON logs.log_id = undelivered_dates_logs.log_id + INNER JOIN cost_logs ON logs.log_id = cost_logs.log_id""" + + if parameters: + conditions = ' AND '.join( + f"logs.{parameter} = ?" + for parameter in parameters + ) + + final_query = f"{columns_only_query} WHERE {conditions};" - # if the user has not provided delivery days, fetch them from the database else: - if isinstance(paper_id, int): - days = [ - (int(day_id), bool(delivered)) - for day_id, delivered in query_database( - generate_sql_query( - 'papers_days_delivered', - columns=['day_id', 'delivered'], - conditions={ - 'paper_id': paper_id - } - ) - ) - ] + final_query = f"{columns_only_query};" - days.sort(key=lambda x: x[0]) + # execute the query + with connect(DATABASE_PATH) as connection: + data = connection.execute(final_query, values).fetchall() - days = [delivered for _, delivered in days] + connection.close() - # if the user has provided prices, extract them - if prices is not None: + return data - costs = [] - encoded_prices = [float(price) for price in SPLIT_REGEX['semicolon'].split(prices.rstrip(';')) if float(price) > 0] - day_count = -1 - for day in days: - if day: - day_count += 1 - cost = encoded_prices[day_count] - else: - cost = 0 +def get_previous_month() -> date_type: + """get the previous month, by looking at 1 day before the first day of the current month (duh)""" - costs.append(cost) + return (datetime.today().replace(day=1) - timedelta(days=1)).replace(day=1) - return days, costs -## validate month and year -def validate_month_and_year(month: int | None = None, year: int | None = None) -> tuple[bool, str]: - if ((month is None) or (isinstance(month, int) and (0 < month) and (month <= 12))) and ((year is None) or (isinstance(year, int) and (year >= 0))): - return True, "" +def validate_month_and_year(month: int | None = None, year: int | None = None) -> None: + """validate month and year + - month must be an integer between 1 and 12 inclusive + - year must be an integer greater than 0""" - return False, "Invalid month and/or year." \ No newline at end of file + if isinstance(month, int) and not (1 <= month <= 12): + raise npbc_exceptions.InvalidMonthYear("Month must be between 1 and 12.") + + if isinstance(year, int) and (year <= 0): + raise npbc_exceptions.InvalidMonthYear("Year must be greater than 0.") diff --git a/npbc_exceptions.py b/npbc_exceptions.py new file mode 100644 index 0000000..85d9a1f --- /dev/null +++ b/npbc_exceptions.py @@ -0,0 +1,16 @@ +""" +provide exceptions for other modules +- these are custom exceptions used to make error handling easier +- none of them inherit from BaseException +""" + + +from sqlite3 import OperationalError + +class InvalidInput(ValueError): ... +class InvalidUndeliveredString(InvalidInput): ... +class PaperAlreadyExists(OperationalError): ... +class PaperNotExists(OperationalError): ... +class StringNotExists(OperationalError): ... +class InvalidMonthYear(InvalidInput): ... +class NoParameters(ValueError): ... \ No newline at end of file diff --git a/npbc_regex.py b/npbc_regex.py new file mode 100644 index 0000000..ee6e171 --- /dev/null +++ b/npbc_regex.py @@ -0,0 +1,38 @@ +""" +regex used by other files +- MATCH regex are used to validate (usually user input) +- SPLIT regex are used to split strings (usually user input) +""" + +from calendar import day_name as WEEKDAY_NAMES_ITERABLE +from re import compile as compile_regex + + +## regex used to match against strings + +# match for a list of comma separated values. each value must be/contain digits, or letters, or hyphens. spaces are allowed between values and commas. any number of values are allowed, but at least one must be present. +CSV_MATCH_REGEX = compile_regex(r'^[-\w]+( *, *[-\w]+)*( *,)?$') + +# match for a single number. must be one or two digits +NUMBER_MATCH_REGEX = compile_regex(r'^[\d]{1,2}?$') + +# match for a range of numbers. each number must be one or two digits. numbers are separated by a hyphen. spaces are allowed between numbers and the hyphen. +RANGE_MATCH_REGEX = compile_regex(r'^\d{1,2} *- *\d{1,2}$') + +# match for weekday name. day must appear as "daynames" (example = "mondays"). all lowercase. +DAYS_MATCH_REGEX = compile_regex(f"^{'|'.join([day_name.lower() + 's' for day_name in WEEKDAY_NAMES_ITERABLE])}$") + +# match for nth weekday name. day must appear as "n-dayname" (example = "1-monday"). all lowercase. must be one digit. +N_DAY_MATCH_REGEX = compile_regex(f"^\\d *- *({'|'.join([day_name.lower() for day_name in WEEKDAY_NAMES_ITERABLE])})$") + +# match for the text "all" in any case. +ALL_MATCH_REGEX = compile_regex(r'^[aA][lL]{2}$') + +# match for seven values, each of which must be a 'Y' or an 'N'. there are no delimiters. +DELIVERY_MATCH_REGEX = compile_regex(r'^[YN]{7}$') + + +## regex used to split strings + +# split on hyphens. spaces are allowed between hyphens and values. +HYPHEN_SPLIT_REGEX = compile_regex(r' *- *') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cced67c..61d2c72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ ## CLI colorama -pyperclip ## API # Flask @@ -8,3 +7,6 @@ pyperclip ## Testing # pytest + +## Building +# pyinstaller \ No newline at end of file diff --git a/test_core.py b/test_core.py index 53a29b7..af29656 100644 --- a/test_core.py +++ b/test_core.py @@ -1,226 +1,86 @@ +""" +test data-independent functions from the core +- none of these depend on data in the database +""" + from datetime import date as date_type -from npbc_core import (SPLIT_REGEX, VALIDATE_REGEX, - calculate_cost_of_one_paper, extract_days_and_costs, - generate_sql_query, get_number_of_days_per_week, - parse_undelivered_string, validate_month_and_year) - - -def test_regex_number(): - assert VALIDATE_REGEX['number'].match('') is None - assert VALIDATE_REGEX['number'].match('1') is not None - assert VALIDATE_REGEX['number'].match('1 2') is None - assert VALIDATE_REGEX['number'].match('1-2') is None - assert VALIDATE_REGEX['number'].match('11') is not None - assert VALIDATE_REGEX['number'].match('11-12') is None - assert VALIDATE_REGEX['number'].match('11-12,13') is None - assert VALIDATE_REGEX['number'].match('11-12,13-14') is None - assert VALIDATE_REGEX['number'].match('111') is None - assert VALIDATE_REGEX['number'].match('a') is None - assert VALIDATE_REGEX['number'].match('1a') is None - assert VALIDATE_REGEX['number'].match('1a2') is None - assert VALIDATE_REGEX['number'].match('12b') is None - -def test_regex_range(): - assert VALIDATE_REGEX['range'].match('') is None - assert VALIDATE_REGEX['range'].match('1') is None - assert VALIDATE_REGEX['range'].match('1 2') is None - assert VALIDATE_REGEX['range'].match('1-2') is not None - assert VALIDATE_REGEX['range'].match('11') is None - assert VALIDATE_REGEX['range'].match('11-') is None - assert VALIDATE_REGEX['range'].match('11-12') is not None - assert VALIDATE_REGEX['range'].match('11-12-1') is None - assert VALIDATE_REGEX['range'].match('11 -12') is not None - assert VALIDATE_REGEX['range'].match('11 - 12') is not None - assert VALIDATE_REGEX['range'].match('11- 12') is not None - assert VALIDATE_REGEX['range'].match('11-2') is not None - assert VALIDATE_REGEX['range'].match('11-12,13') is None - assert VALIDATE_REGEX['range'].match('11-12,13-14') is None - assert VALIDATE_REGEX['range'].match('111') is None - assert VALIDATE_REGEX['range'].match('a') is None - assert VALIDATE_REGEX['range'].match('1a') is None - assert VALIDATE_REGEX['range'].match('1a2') is None - assert VALIDATE_REGEX['range'].match('12b') is None - assert VALIDATE_REGEX['range'].match('11-a') is None - assert VALIDATE_REGEX['range'].match('11-12a') is None - -def test_regex_CSVs(): - assert VALIDATE_REGEX['CSVs'].match('') is None - assert VALIDATE_REGEX['CSVs'].match('1') is not None - assert VALIDATE_REGEX['CSVs'].match('a') is not None - assert VALIDATE_REGEX['CSVs'].match('adcef') is not None - assert VALIDATE_REGEX['CSVs'].match('-') is not None - assert VALIDATE_REGEX['CSVs'].match(' ') is None - assert VALIDATE_REGEX['CSVs'].match('1,2') is not None - assert VALIDATE_REGEX['CSVs'].match('1-3') is not None - assert VALIDATE_REGEX['CSVs'].match('monday') is not None - assert VALIDATE_REGEX['CSVs'].match('monday,tuesday') is not None - assert VALIDATE_REGEX['CSVs'].match('mondays') is not None - assert VALIDATE_REGEX['CSVs'].match('tuesdays') is not None - assert VALIDATE_REGEX['CSVs'].match('1,2,3') is not None - assert VALIDATE_REGEX['CSVs'].match('1-3') is not None - assert VALIDATE_REGEX['CSVs'].match('monday,tuesday') is not None - assert VALIDATE_REGEX['CSVs'].match('mondays,tuesdays') is not None - assert VALIDATE_REGEX['CSVs'].match(';') is None - assert VALIDATE_REGEX['CSVs'].match(':') is None - assert VALIDATE_REGEX['CSVs'].match(':') is None - assert VALIDATE_REGEX['CSVs'].match('!') is None - assert VALIDATE_REGEX['CSVs'].match('1,2,3,4') is not None - -def test_regex_days(): - assert VALIDATE_REGEX['days'].match('') is None - assert VALIDATE_REGEX['days'].match('1') is None - assert VALIDATE_REGEX['days'].match('1,2') is None - assert VALIDATE_REGEX['days'].match('1-3') is None - assert VALIDATE_REGEX['days'].match('monday') is None - assert VALIDATE_REGEX['days'].match('monday,tuesday') is None - assert VALIDATE_REGEX['days'].match('mondays') is not None - assert VALIDATE_REGEX['days'].match('tuesdays') is not None - -def test_regex_n_days(): - assert VALIDATE_REGEX['n-day'].match('') is None - assert VALIDATE_REGEX['n-day'].match('1') is None - assert VALIDATE_REGEX['n-day'].match('1-') is None - assert VALIDATE_REGEX['n-day'].match('1,2') is None - assert VALIDATE_REGEX['n-day'].match('1-3') is None - assert VALIDATE_REGEX['n-day'].match('monday') is None - assert VALIDATE_REGEX['n-day'].match('monday,tuesday') is None - assert VALIDATE_REGEX['n-day'].match('mondays') is None - assert VALIDATE_REGEX['n-day'].match('1-tuesday') is not None - assert VALIDATE_REGEX['n-day'].match('11-tuesday') is None - assert VALIDATE_REGEX['n-day'].match('111-tuesday') is None - assert VALIDATE_REGEX['n-day'].match('11-tuesdays') is None - assert VALIDATE_REGEX['n-day'].match('1 -tuesday') is not None - assert VALIDATE_REGEX['n-day'].match('1- tuesday') is not None - assert VALIDATE_REGEX['n-day'].match('1 - tuesday') is not None - -def test_regex_costs(): - assert VALIDATE_REGEX['costs'].match('') is None - assert VALIDATE_REGEX['costs'].match('a') is None - assert VALIDATE_REGEX['costs'].match('1') is not None - assert VALIDATE_REGEX['costs'].match('1.') is None - assert VALIDATE_REGEX['costs'].match('1.5') is not None - assert VALIDATE_REGEX['costs'].match('1.0') is not None - assert VALIDATE_REGEX['costs'].match('16.0') is not None - assert VALIDATE_REGEX['costs'].match('16.06') is not None - assert VALIDATE_REGEX['costs'].match('1;2') is not None - assert VALIDATE_REGEX['costs'].match('1 ;2') is not None - assert VALIDATE_REGEX['costs'].match('1; 2') is not None - assert VALIDATE_REGEX['costs'].match('1 ; 2') is not None - assert VALIDATE_REGEX['costs'].match('1;2;') is not None - assert VALIDATE_REGEX['costs'].match('1;2 ;') is not None - assert VALIDATE_REGEX['costs'].match('1:2') is None - assert VALIDATE_REGEX['costs'].match('1,2') is None - assert VALIDATE_REGEX['costs'].match('1-2') is None - assert VALIDATE_REGEX['costs'].match('1;2;3') is not None - assert VALIDATE_REGEX['costs'].match('1;2;3;4') is not None - assert VALIDATE_REGEX['costs'].match('1;2;3;4;5') is not None - assert VALIDATE_REGEX['costs'].match('1;2;3;4;5;6') is not None - assert VALIDATE_REGEX['costs'].match('1;2;3;4;5;6;7;') is not None - assert VALIDATE_REGEX['costs'].match('1;2;3;4;5;6;7') is not None - assert VALIDATE_REGEX['costs'].match('1;2;3;4;5;6;7;8') is None - -def test_delivery_regex(): - assert VALIDATE_REGEX['delivery'].match('') is None - assert VALIDATE_REGEX['delivery'].match('a') is None - assert VALIDATE_REGEX['delivery'].match('1') is None - assert VALIDATE_REGEX['delivery'].match('1.') is None - assert VALIDATE_REGEX['delivery'].match('1.5') is None - assert VALIDATE_REGEX['delivery'].match('1,2') is None - assert VALIDATE_REGEX['delivery'].match('1-2') is None - assert VALIDATE_REGEX['delivery'].match('1;2') is None - assert VALIDATE_REGEX['delivery'].match('1:2') is None - assert VALIDATE_REGEX['delivery'].match('1,2,3') is None - assert VALIDATE_REGEX['delivery'].match('Y') is None - assert VALIDATE_REGEX['delivery'].match('N') is None - assert VALIDATE_REGEX['delivery'].match('YY') is None - assert VALIDATE_REGEX['delivery'].match('YYY') is None - assert VALIDATE_REGEX['delivery'].match('YYYY') is None - assert VALIDATE_REGEX['delivery'].match('YYYYY') is None - assert VALIDATE_REGEX['delivery'].match('YYYYYY') is None - assert VALIDATE_REGEX['delivery'].match('YYYYYYY') is not None - assert VALIDATE_REGEX['delivery'].match('YYYYYYYY') is None - assert VALIDATE_REGEX['delivery'].match('NNNNNNN') is not None - assert VALIDATE_REGEX['delivery'].match('NYNNNNN') is not None - assert VALIDATE_REGEX['delivery'].match('NYYYYNN') is not None - assert VALIDATE_REGEX['delivery'].match('NYYYYYY') is not None - assert VALIDATE_REGEX['delivery'].match('NYYYYYYY') is None - assert VALIDATE_REGEX['delivery'].match('N,N,N,N,N,N,N') is None - assert VALIDATE_REGEX['delivery'].match('N;N;N;N;N;N;N') is None - assert VALIDATE_REGEX['delivery'].match('N-N-N-N-N-N-N') is None - assert VALIDATE_REGEX['delivery'].match('N N N N N N N') is None - assert VALIDATE_REGEX['delivery'].match('YYYYYYy') is None - assert VALIDATE_REGEX['delivery'].match('YYYYYYn') is None - - - -def test_regex_hyphen(): - assert SPLIT_REGEX['hyphen'].split('1-2') == ['1', '2'] - assert SPLIT_REGEX['hyphen'].split('1-2-3') == ['1', '2', '3'] - assert SPLIT_REGEX['hyphen'].split('1 -2-3') == ['1', '2', '3'] - assert SPLIT_REGEX['hyphen'].split('1 - 2-3') == ['1', '2', '3'] - assert SPLIT_REGEX['hyphen'].split('1- 2-3') == ['1', '2', '3'] - assert SPLIT_REGEX['hyphen'].split('1') == ['1'] - assert SPLIT_REGEX['hyphen'].split('1-') == ['1', ''] - assert SPLIT_REGEX['hyphen'].split('1-2-') == ['1', '2', ''] - assert SPLIT_REGEX['hyphen'].split('1-2-3-') == ['1', '2', '3', ''] - assert SPLIT_REGEX['hyphen'].split('1,2-3') == ['1,2', '3'] - assert SPLIT_REGEX['hyphen'].split('1,2-3-') == ['1,2', '3', ''] - assert SPLIT_REGEX['hyphen'].split('1,2, 3,') == ['1,2, 3,'] - assert SPLIT_REGEX['hyphen'].split('') == [''] - -def test_regex_comma(): - assert SPLIT_REGEX['comma'].split('1,2') == ['1', '2'] - assert SPLIT_REGEX['comma'].split('1,2,3') == ['1', '2', '3'] - assert SPLIT_REGEX['comma'].split('1 ,2,3') == ['1', '2', '3'] - assert SPLIT_REGEX['comma'].split('1 , 2,3') == ['1', '2', '3'] - assert SPLIT_REGEX['comma'].split('1, 2,3') == ['1', '2', '3'] - assert SPLIT_REGEX['comma'].split('1') == ['1'] - assert SPLIT_REGEX['comma'].split('1,') == ['1', ''] - assert SPLIT_REGEX['comma'].split('1, ') == ['1', ''] - assert SPLIT_REGEX['comma'].split('1,2,') == ['1', '2', ''] - assert SPLIT_REGEX['comma'].split('1,2,3,') == ['1', '2', '3', ''] - assert SPLIT_REGEX['comma'].split('1-2,3') == ['1-2', '3'] - assert SPLIT_REGEX['comma'].split('1-2,3,') == ['1-2', '3', ''] - assert SPLIT_REGEX['comma'].split('1-2-3') == ['1-2-3'] - assert SPLIT_REGEX['comma'].split('1-2- 3') == ['1-2- 3'] - assert SPLIT_REGEX['comma'].split('') == [''] - -def test_regex_semicolon(): - assert SPLIT_REGEX['semicolon'].split('1;2') == ['1', '2'] - assert SPLIT_REGEX['semicolon'].split('1;2;3') == ['1', '2', '3'] - assert SPLIT_REGEX['semicolon'].split('1 ;2;3') == ['1', '2', '3'] - assert SPLIT_REGEX['semicolon'].split('1 ; 2;3') == ['1', '2', '3'] - assert SPLIT_REGEX['semicolon'].split('1; 2;3') == ['1', '2', '3'] - assert SPLIT_REGEX['semicolon'].split('1') == ['1'] - assert SPLIT_REGEX['semicolon'].split('1;') == ['1', ''] - assert SPLIT_REGEX['semicolon'].split('1; ') == ['1', ''] - assert SPLIT_REGEX['semicolon'].split('1;2;') == ['1', '2', ''] - assert SPLIT_REGEX['semicolon'].split('1;2;3;') == ['1', '2', '3', ''] - assert SPLIT_REGEX['semicolon'].split('1-2;3') == ['1-2', '3'] - assert SPLIT_REGEX['semicolon'].split('1-2;3;') == ['1-2', '3', ''] - assert SPLIT_REGEX['semicolon'].split('1-2-3') == ['1-2-3'] - assert SPLIT_REGEX['semicolon'].split('1-2- 3') == ['1-2- 3'] - assert SPLIT_REGEX['semicolon'].split('') == [''] +from pytest import raises + +import npbc_core +from npbc_exceptions import InvalidMonthYear, InvalidUndeliveredString + + +def test_get_number_of_each_weekday(): + test_function = npbc_core.get_number_of_each_weekday + + assert list(test_function(1, 2022)) == [5, 4, 4, 4, 4, 5, 5] + assert list(test_function(2, 2022)) == [4, 4, 4, 4, 4, 4, 4] + assert list(test_function(3, 2022)) == [4, 5, 5 ,5, 4, 4, 4] + assert list(test_function(2, 2020)) == [4, 4, 4, 4, 4, 5, 4] + assert list(test_function(12, 1954)) == [4, 4, 5, 5, 5, 4, 4] + + +def test_validate_undelivered_string(): + test_function = npbc_core.validate_undelivered_string + + with raises(InvalidUndeliveredString): + test_function("a") + test_function("monday") + test_function("1-mondays") + test_function("1monday") + test_function("1 monday") + test_function("monday-1") + test_function("monday-1") + + test_function("") + test_function("1") + test_function("6") + test_function("31") + test_function("31","") + test_function("3","1") + test_function("3","1","") + test_function("3","1") + test_function("3","1") + test_function("3","1") + test_function("1","2","3-9") + test_function("1","2","3-9","11","12","13-19") + test_function("1","2","3-9","11","12","13-19","21","22","23-29") + test_function("1","2","3-9","11","12","13-19","21","22","23-29","31") + test_function("1","2","3","4","5","6","7","8","9") + test_function("mondays") + test_function("mondays,tuesdays") + test_function("mondays","tuesdays","wednesdays") + test_function("mondays","5-21") + test_function("mondays","5-21","tuesdays","5-21") + test_function("1-monday") + test_function("2-monday") + test_function("all") + test_function("All") + test_function("aLl") + test_function("alL") + test_function("aLL") + test_function("ALL") def test_undelivered_string_parsing(): MONTH = 5 YEAR = 2017 + test_function = npbc_core.parse_undelivered_strings - assert parse_undelivered_string('', MONTH, YEAR) == set([]) + assert test_function(MONTH, YEAR, '') == set([]) - assert parse_undelivered_string('1', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, '1') == set([ date_type(year=YEAR, month=MONTH, day=1) ]) - assert parse_undelivered_string('1-2', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, '1-2') == set([ date_type(year=YEAR, month=MONTH, day=1), date_type(year=YEAR, month=MONTH, day=2) ]) - assert parse_undelivered_string('5-17', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, '5-17') == set([ date_type(year=YEAR, month=MONTH, day=5), date_type(year=YEAR, month=MONTH, day=6), date_type(year=YEAR, month=MONTH, day=7), @@ -236,7 +96,7 @@ def test_undelivered_string_parsing(): date_type(year=YEAR, month=MONTH, day=17) ]) - assert parse_undelivered_string('5-17,19', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, '5-17', '19') == set([ date_type(year=YEAR, month=MONTH, day=5), date_type(year=YEAR, month=MONTH, day=6), date_type(year=YEAR, month=MONTH, day=7), @@ -253,7 +113,7 @@ def test_undelivered_string_parsing(): date_type(year=YEAR, month=MONTH, day=19) ]) - assert parse_undelivered_string('5-17,19-21', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, '5-17', '19-21') == set([ date_type(year=YEAR, month=MONTH, day=5), date_type(year=YEAR, month=MONTH, day=6), date_type(year=YEAR, month=MONTH, day=7), @@ -272,7 +132,7 @@ def test_undelivered_string_parsing(): date_type(year=YEAR, month=MONTH, day=21) ]) - assert parse_undelivered_string('5-17,19-21,23', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, '5-17', '19-21', '23') == set([ date_type(year=YEAR, month=MONTH, day=5), date_type(year=YEAR, month=MONTH, day=6), date_type(year=YEAR, month=MONTH, day=7), @@ -292,7 +152,7 @@ def test_undelivered_string_parsing(): date_type(year=YEAR, month=MONTH, day=23) ]) - assert parse_undelivered_string('mondays', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, 'mondays') == set([ date_type(year=YEAR, month=MONTH, day=1), date_type(year=YEAR, month=MONTH, day=8), date_type(year=YEAR, month=MONTH, day=15), @@ -300,7 +160,7 @@ def test_undelivered_string_parsing(): date_type(year=YEAR, month=MONTH, day=29) ]) - assert parse_undelivered_string('mondays, wednesdays', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, 'mondays', 'wednesdays') == set([ date_type(year=YEAR, month=MONTH, day=1), date_type(year=YEAR, month=MONTH, day=8), date_type(year=YEAR, month=MONTH, day=15), @@ -313,62 +173,16 @@ def test_undelivered_string_parsing(): date_type(year=YEAR, month=MONTH, day=31) ]) - assert parse_undelivered_string('2-monday', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, '2-monday') == set([ date_type(year=YEAR, month=MONTH, day=8) ]) - assert parse_undelivered_string('2-monday, 3-wednesday', MONTH, YEAR) == set([ + assert test_function(MONTH, YEAR, '2-monday', '3-wednesday') == set([ date_type(year=YEAR, month=MONTH, day=8), date_type(year=YEAR, month=MONTH, day=17) ]) -def test_sql_query(): - assert generate_sql_query( - 'test' - ) == "SELECT * FROM test;" - - assert generate_sql_query( - 'test', - columns=['a'] - ) == "SELECT a FROM test;" - - assert generate_sql_query( - 'test', - columns=['a', 'b'] - ) == "SELECT a, b FROM test;" - - assert generate_sql_query( - 'test', - conditions={'a': '\"b\"'} - ) == "SELECT * FROM test WHERE a = \"b\";" - - assert generate_sql_query( - 'test', - conditions={ - 'a': '\"b\"', - 'c': '\"d\"' - } - ) == "SELECT * FROM test WHERE a = \"b\" AND c = \"d\";" - - assert generate_sql_query( - 'test', - conditions={ - 'a': '\"b\"', - 'c': '\"d\"' - }, - columns=['a', 'b'] - ) == "SELECT a, b FROM test WHERE a = \"b\" AND c = \"d\";" - - -def test_number_of_days_per_week(): - assert get_number_of_days_per_week(1, 2022) == [5, 4, 4, 4, 4, 5, 5] - assert get_number_of_days_per_week(2, 2022) == [4, 4, 4, 4, 4, 4, 4] - assert get_number_of_days_per_week(3, 2022) == [4, 5, 5 ,5, 4, 4, 4] - assert get_number_of_days_per_week(2, 2020) == [4, 4, 4, 4, 4, 5, 4] - assert get_number_of_days_per_week(12, 1954) == [4, 4, 5, 5, 5, 4, 4] - - def test_calculating_cost_of_one_paper(): DAYS_PER_WEEK = [5, 4, 4, 4, 4, 5, 5] COST_PER_DAY: dict[int, float] = { @@ -389,8 +203,10 @@ def test_calculating_cost_of_one_paper(): 5: False, 6: True } - - assert calculate_cost_of_one_paper( + + test_function = npbc_core.calculate_cost_of_one_paper + + assert test_function( DAYS_PER_WEEK, set([]), ( @@ -399,7 +215,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 41 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([]), ( @@ -416,7 +232,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 36 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([ date_type(year=2022, month=1, day=8) @@ -427,7 +243,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 41 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([ date_type(year=2022, month=1, day=8), @@ -439,7 +255,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 41 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([ date_type(year=2022, month=1, day=8), @@ -451,7 +267,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 41 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([ date_type(year=2022, month=1, day=2) @@ -462,7 +278,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 40 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([ date_type(year=2022, month=1, day=2), @@ -474,7 +290,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 40 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([ date_type(year=2022, month=1, day=6), @@ -486,7 +302,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 34 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([ date_type(year=2022, month=1, day=6), @@ -499,7 +315,7 @@ def test_calculating_cost_of_one_paper(): ) ) == 34 - assert calculate_cost_of_one_paper( + assert test_function( DAYS_PER_WEEK, set([ date_type(year=2022, month=1, day=6), @@ -517,48 +333,24 @@ def test_calculating_cost_of_one_paper(): ) == 34 -def test_extracting_days_and_costs(): - assert extract_days_and_costs(None, None) == ([], []) - assert extract_days_and_costs('NNNNNNN', None) == ( - [False, False, False, False, False, False, False], - [] - ) - - assert extract_days_and_costs('NNNYNNN', '7') == ( - [False, False, False, True, False, False, False], - [0, 0, 0, 7, 0, 0, 0] - ) - - assert extract_days_and_costs('NNNYNNN', '7;7') == ( - [False, False, False, True, False, False, False], - [0, 0, 0, 7, 0, 0, 0] - ) - - assert extract_days_and_costs('NNNYNNY', '7;4') == ( - [False, False, False, True, False, False, True], - [0, 0, 0, 7, 0, 0, 4] - ) - - assert extract_days_and_costs('NNNYNNY', '7;4.7') == ( - [False, False, False, True, False, False, True], - [0, 0, 0, 7, 0, 0, 4.7] - ) - - def test_validate_month_and_year(): - assert validate_month_and_year(1, 2020)[0] - assert validate_month_and_year(12, 2020)[0] - assert validate_month_and_year(1, 2021)[0] - assert validate_month_and_year(12, 2021)[0] - assert validate_month_and_year(1, 2022)[0] - assert validate_month_and_year(12, 2022)[0] - assert not validate_month_and_year(-54, 2020)[0] - assert not validate_month_and_year(0, 2020)[0] - assert not validate_month_and_year(13, 2020)[0] - assert not validate_month_and_year(45, 2020)[0] - assert not validate_month_and_year(1, -5)[0] - assert not validate_month_and_year(12, -5)[0] - assert not validate_month_and_year(1.6, 10)[0] # type: ignore - assert not validate_month_and_year(12.6, 10)[0] # type: ignore - assert not validate_month_and_year(1, '10')[0] # type: ignore - assert not validate_month_and_year(12, '10')[0] # type: ignore + test_function = npbc_core.validate_month_and_year + + test_function(1, 2020) + test_function(12, 2020) + test_function(1, 2021) + test_function(12, 2021) + test_function(1, 2022) + test_function(12, 2022) + + with raises(InvalidMonthYear): + test_function(-54, 2020) + test_function(0, 2020) + test_function(13, 2020) + test_function(45, 2020) + test_function(1, -5) + test_function(12, -5) + test_function(1.6, 10) # type: ignore + test_function(12.6, 10) # type: ignore + test_function(1, '10') # type: ignore + test_function(12, '10') # type: ignore diff --git a/test_db.py b/test_db.py new file mode 100644 index 0000000..ba674a1 --- /dev/null +++ b/test_db.py @@ -0,0 +1,303 @@ +""" +test data-dependent functions from the core +- all of these depend on the DB +- for consistency, the DB will always be initialised with the same data +- the test data is contained in `data/test.sql` +- the schema is the same as the core (`data/schema.sql` during development) +""" + + +from datetime import date, datetime +from pathlib import Path +from sqlite3 import connect +from typing import Counter + +from pytest import raises + +import npbc_core +import npbc_exceptions + +DATABASE_PATH = Path("data") / "npbc.db" +SCHEMA_PATH = Path("data") / "schema.sql" +TEST_SQL = Path("data") / "test.sql" + + +def setup_db(database_path: Path, schema_path: Path, test_sql: Path): + DATABASE_PATH.unlink(missing_ok=True) + + with connect(database_path) as connection: + connection.executescript(schema_path.read_text()) + connection.commit() + connection.executescript(test_sql.read_text()) + + connection.close() + + +def test_get_papers(): + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + + known_data = [ + (1, 'paper1', 0, 0, 0), + (1, 'paper1', 1, 1, 6.4), + (1, 'paper1', 2, 0, 0), + (1, 'paper1', 3, 0, 0), + (1, 'paper1', 4, 0, 0), + (1, 'paper1', 5, 1, 7.9), + (1, 'paper1', 6, 1, 4), + (2, 'paper2', 0, 0, 0), + (2, 'paper2', 1, 0, 0), + (2, 'paper2', 2, 0, 0), + (2, 'paper2', 3, 0, 0), + (2, 'paper2', 4, 1, 3.4), + (2, 'paper2', 5, 0, 0), + (2, 'paper2', 6, 1, 8.4), + (3, 'paper3', 0, 1, 2.4), + (3, 'paper3', 1, 1, 4.6), + (3, 'paper3', 2, 0, 0), + (3, 'paper3', 3, 0, 0), + (3, 'paper3', 4, 1, 3.4), + (3, 'paper3', 5, 1, 4.6), + (3, 'paper3', 6, 1, 6) + ] + + assert Counter(npbc_core.get_papers()) == Counter(known_data) + + +def test_get_undelivered_strings(): + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + + known_data = [ + (1, 1, 2020, 11, '5'), + (2, 1, 2020, 11, '6-12'), + (3, 2, 2020, 11, 'sundays'), + (4, 3, 2020, 11, '2-tuesday'), + (5, 3, 2020, 10, 'all') + ] + + assert Counter(npbc_core.get_undelivered_strings()) == Counter(known_data) + assert Counter(npbc_core.get_undelivered_strings(string_id=3)) == Counter([known_data[2]]) + assert Counter(npbc_core.get_undelivered_strings(month=11)) == Counter(known_data[:4]) + assert Counter(npbc_core.get_undelivered_strings(paper_id=1)) == Counter(known_data[:2]) + assert Counter(npbc_core.get_undelivered_strings(paper_id=1, string='6-12')) == Counter([known_data[1]]) + + with raises(npbc_exceptions.StringNotExists): + npbc_core.get_undelivered_strings(year=1986) + + +def test_delete_paper(): + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + + npbc_core.delete_existing_paper(2) + + known_data = [ + (1, 'paper1', 0, 0, 0), + (1, 'paper1', 1, 1, 6.4), + (1, 'paper1', 2, 0, 0), + (1, 'paper1', 3, 0, 0), + (1, 'paper1', 4, 0, 0), + (1, 'paper1', 5, 1, 7.9), + (1, 'paper1', 6, 1, 4), + (3, 'paper3', 0, 1, 2.4), + (3, 'paper3', 1, 1, 4.6), + (3, 'paper3', 2, 0, 0), + (3, 'paper3', 3, 0, 0), + (3, 'paper3', 4, 1, 3.4), + (3, 'paper3', 5, 1, 4.6), + (3, 'paper3', 6, 1, 6) + ] + + assert Counter(npbc_core.get_papers()) == Counter(known_data) + + with raises(npbc_exceptions.PaperNotExists): + npbc_core.delete_existing_paper(7) + npbc_core.delete_existing_paper(2) + + +def test_add_paper(): + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + + known_data = [ + (1, 'paper1', 0, 0, 0), + (1, 'paper1', 1, 1, 6.4), + (1, 'paper1', 2, 0, 0), + (1, 'paper1', 3, 0, 0), + (1, 'paper1', 4, 0, 0), + (1, 'paper1', 5, 1, 7.9), + (1, 'paper1', 6, 1, 4), + (2, 'paper2', 0, 0, 0), + (2, 'paper2', 1, 0, 0), + (2, 'paper2', 2, 0, 0), + (2, 'paper2', 3, 0, 0), + (2, 'paper2', 4, 1, 3.4), + (2, 'paper2', 5, 0, 0), + (2, 'paper2', 6, 1, 8.4), + (3, 'paper3', 0, 1, 2.4), + (3, 'paper3', 1, 1, 4.6), + (3, 'paper3', 2, 0, 0), + (3, 'paper3', 3, 0, 0), + (3, 'paper3', 4, 1, 3.4), + (3, 'paper3', 5, 1, 4.6), + (3, 'paper3', 6, 1, 6), + (4, 'paper4', 0, 1, 4), + (4, 'paper4', 1, 0, 0), + (4, 'paper4', 2, 1, 2.6), + (4, 'paper4', 3, 0, 0), + (4, 'paper4', 4, 0, 0), + (4, 'paper4', 5, 1, 1), + (4, 'paper4', 6, 1, 7) + ] + + npbc_core.add_new_paper( + 'paper4', + [True, False, True, False, False, True, True], + [4, 0, 2.6, 0, 0, 1, 7] + ) + + assert Counter(npbc_core.get_papers()) == Counter(known_data) + + with raises(npbc_exceptions.PaperAlreadyExists): + npbc_core.add_new_paper( + 'paper4', + [True, False, True, False, False, True, True], + [4, 0, 2.6, 0, 0, 1, 7] + ) + + +def test_edit_paper(): + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + + known_data = [ + (1, 'paper1', 0, 0, 0), + (1, 'paper1', 1, 1, 6.4), + (1, 'paper1', 2, 0, 0), + (1, 'paper1', 3, 0, 0), + (1, 'paper1', 4, 0, 0), + (1, 'paper1', 5, 1, 7.9), + (1, 'paper1', 6, 1, 4), + (2, 'paper2', 0, 0, 0), + (2, 'paper2', 1, 0, 0), + (2, 'paper2', 2, 0, 0), + (2, 'paper2', 3, 0, 0), + (2, 'paper2', 4, 1, 3.4), + (2, 'paper2', 5, 0, 0), + (2, 'paper2', 6, 1, 8.4), + (3, 'paper3', 0, 1, 2.4), + (3, 'paper3', 1, 1, 4.6), + (3, 'paper3', 2, 0, 0), + (3, 'paper3', 3, 0, 0), + (3, 'paper3', 4, 1, 3.4), + (3, 'paper3', 5, 1, 4.6), + (3, 'paper3', 6, 1, 6) + ] + + npbc_core.edit_existing_paper( + 1, + days_delivered=[True, False, True, False, False, True, True], + days_cost=[6.4, 0, 0, 0, 0, 7.9, 4] + ) + + known_data[0] = (1, 'paper1', 0, 1, 6.4) + known_data[1] = (1, 'paper1', 1, 0, 0) + known_data[2] = (1, 'paper1', 2, 1, 0) + known_data[3] = (1, 'paper1', 3, 0, 0) + known_data[4] = (1, 'paper1', 4, 0, 0) + known_data[5] = (1, 'paper1', 5, 1, 7.9) + known_data[6] = (1, 'paper1', 6, 1, 4) + + assert Counter(npbc_core.get_papers()) == Counter(known_data) + + npbc_core.edit_existing_paper( + 3, + name="New paper" + ) + + known_data[14] = (3, 'New paper', 0, 1, 2.4) + known_data[15] = (3, 'New paper', 1, 1, 4.6) + known_data[16] = (3, 'New paper', 2, 0, 0) + known_data[17] = (3, 'New paper', 3, 0, 0) + known_data[18] = (3, 'New paper', 4, 1, 3.4) + known_data[19] = (3, 'New paper', 5, 1, 4.6) + known_data[20] = (3, 'New paper', 6, 1, 6) + + assert Counter(npbc_core.get_papers()) == Counter(known_data) + + with raises(npbc_exceptions.PaperNotExists): + npbc_core.edit_existing_paper(7, name="New paper") + + +def test_delete_string(): + known_data = [ + (1, 1, 2020, 11, '5'), + (2, 1, 2020, 11, '6-12'), + (3, 2, 2020, 11, 'sundays'), + (4, 3, 2020, 11, '2-tuesday'), + (5, 3, 2020, 10, 'all') + ] + + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + npbc_core.delete_undelivered_string(string='all') + assert Counter(npbc_core.get_undelivered_strings()) == Counter(known_data[:4]) + + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + npbc_core.delete_undelivered_string(month=11) + assert Counter(npbc_core.get_undelivered_strings()) == Counter([known_data[4]]) + + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + npbc_core.delete_undelivered_string(paper_id=1) + assert Counter(npbc_core.get_undelivered_strings()) == Counter(known_data[2:]) + + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + + with raises(npbc_exceptions.StringNotExists): + npbc_core.delete_undelivered_string(string='not exists') + + with raises(npbc_exceptions.NoParameters): + npbc_core.delete_undelivered_string() + + +def test_add_string(): + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + + known_data = [ + (1, 1, 2020, 11, '5'), + (2, 1, 2020, 11, '6-12'), + (3, 2, 2020, 11, 'sundays'), + (4, 3, 2020, 11, '2-tuesday'), + (5, 3, 2020, 10, 'all') + ] + + npbc_core.add_undelivered_string(4, 2017, 3, 'sundays') + known_data.append((6, 3, 2017, 4, 'sundays')) + assert Counter(npbc_core.get_undelivered_strings()) == Counter(known_data) + + npbc_core.add_undelivered_string(9, 2017, None, '11') + known_data.append((7, 1, 2017, 9, '11')) + known_data.append((8, 2, 2017, 9, '11')) + known_data.append((9, 3, 2017, 9, '11')) + assert Counter(npbc_core.get_undelivered_strings()) == Counter(known_data) + + +def test_save_results(): + setup_db(DATABASE_PATH, SCHEMA_PATH, TEST_SQL) + + known_data = [ + (1, 1, 1, 2020, '04/01/2022 01:05:42 AM', '2020-01-01', 105.0), + (1, 1, 1, 2020, '04/01/2022 01:05:42 AM', '2020-01-02', 105.0), + (2, 2, 1, 2020, '04/01/2022 01:05:42 AM', '2020-01-03', 51.0), + (2, 2, 1, 2020, '04/01/2022 01:05:42 AM', '2020-01-01', 51.0), + (2, 2, 1, 2020, '04/01/2022 01:05:42 AM', '2020-01-05', 51.0) + ] + + npbc_core.save_results( + {1: 105, 2: 51, 3: 647}, + { + 1: set([date(month=1, day=1, year=2020), date(month=1, day=2, year=2020)]), + 2: set([date(month=1, day=1, year=2020), date(month=1, day=5, year=2020), date(month=1, day=3, year=2020)]), + 3: set() + }, + 1, + 2020, + datetime(year=2022, month=1, day=4, hour=1, minute=5, second=42) + ) + + assert Counter(npbc_core.get_logged_data()) == Counter(known_data) diff --git a/test_regex.py b/test_regex.py new file mode 100644 index 0000000..e0e4959 --- /dev/null +++ b/test_regex.py @@ -0,0 +1,162 @@ +import npbc_regex + +def test_regex_number(): + assert npbc_regex.NUMBER_MATCH_REGEX.match('') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('1') is not None + assert npbc_regex.NUMBER_MATCH_REGEX.match('1 2') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('1-2') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('11') is not None + assert npbc_regex.NUMBER_MATCH_REGEX.match('11-12') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('11-12,13') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('11-12,13-14') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('111') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('a') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('1a') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('1a2') is None + assert npbc_regex.NUMBER_MATCH_REGEX.match('12b') is None + +def test_regex_range(): + assert npbc_regex.RANGE_MATCH_REGEX.match('') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('1') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('1 2') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('1-2') is not None + assert npbc_regex.RANGE_MATCH_REGEX.match('11') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('11-') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('11-12') is not None + assert npbc_regex.RANGE_MATCH_REGEX.match('11-12-1') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('11 -12') is not None + assert npbc_regex.RANGE_MATCH_REGEX.match('11 - 12') is not None + assert npbc_regex.RANGE_MATCH_REGEX.match('11- 12') is not None + assert npbc_regex.RANGE_MATCH_REGEX.match('11-2') is not None + assert npbc_regex.RANGE_MATCH_REGEX.match('11-12,13') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('11-12,13-14') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('111') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('a') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('1a') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('1a2') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('12b') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('11-a') is None + assert npbc_regex.RANGE_MATCH_REGEX.match('11-12a') is None + +def test_regex_CSVs(): + assert npbc_regex.CSV_MATCH_REGEX.match('') is None + assert npbc_regex.CSV_MATCH_REGEX.match('1') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('a') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('adcef') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('-') is not None + assert npbc_regex.CSV_MATCH_REGEX.match(' ') is None + assert npbc_regex.CSV_MATCH_REGEX.match('1,2') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('1-3') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('monday') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('monday,tuesday') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('mondays') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('tuesdays') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('1,2,3') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('1-3') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('monday,tuesday') is not None + assert npbc_regex.CSV_MATCH_REGEX.match('mondays,tuesdays') is not None + assert npbc_regex.CSV_MATCH_REGEX.match(';') is None + assert npbc_regex.CSV_MATCH_REGEX.match(':') is None + assert npbc_regex.CSV_MATCH_REGEX.match(':') is None + assert npbc_regex.CSV_MATCH_REGEX.match('!') is None + assert npbc_regex.CSV_MATCH_REGEX.match('1,2,3,4') is not None + +def test_regex_days(): + assert npbc_regex.DAYS_MATCH_REGEX.match('') is None + assert npbc_regex.DAYS_MATCH_REGEX.match('1') is None + assert npbc_regex.DAYS_MATCH_REGEX.match('1,2') is None + assert npbc_regex.DAYS_MATCH_REGEX.match('1-3') is None + assert npbc_regex.DAYS_MATCH_REGEX.match('monday') is None + assert npbc_regex.DAYS_MATCH_REGEX.match('monday,tuesday') is None + assert npbc_regex.DAYS_MATCH_REGEX.match('mondays') is not None + assert npbc_regex.DAYS_MATCH_REGEX.match('tuesdays') is not None + +def test_regex_n_days(): + assert npbc_regex.N_DAY_MATCH_REGEX.match('') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('1') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('1-') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('1,2') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('1-3') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('monday') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('monday,tuesday') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('mondays') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('1-tuesday') is not None + assert npbc_regex.N_DAY_MATCH_REGEX.match('11-tuesday') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('111-tuesday') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('11-tuesdays') is None + assert npbc_regex.N_DAY_MATCH_REGEX.match('1 -tuesday') is not None + assert npbc_regex.N_DAY_MATCH_REGEX.match('1- tuesday') is not None + assert npbc_regex.N_DAY_MATCH_REGEX.match('1 - tuesday') is not None + +def test_regex_all_text(): + assert npbc_regex.ALL_MATCH_REGEX.match('') is None + assert npbc_regex.ALL_MATCH_REGEX.match('1') is None + assert npbc_regex.ALL_MATCH_REGEX.match('1-') is None + assert npbc_regex.ALL_MATCH_REGEX.match('1,2') is None + assert npbc_regex.ALL_MATCH_REGEX.match('1-3') is None + assert npbc_regex.ALL_MATCH_REGEX.match('monday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('monday,tuesday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('mondays') is None + assert npbc_regex.ALL_MATCH_REGEX.match('tuesdays') is None + assert npbc_regex.ALL_MATCH_REGEX.match('1-tuesday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('11-tuesday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('111-tuesday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('11-tuesdays') is None + assert npbc_regex.ALL_MATCH_REGEX.match('1 -tuesday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('1- tuesday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('1 - tuesday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('all') is not None + assert npbc_regex.ALL_MATCH_REGEX.match('all,tuesday') is None + assert npbc_regex.ALL_MATCH_REGEX.match('all,tuesdays') is None + assert npbc_regex.ALL_MATCH_REGEX.match('All') is not None + assert npbc_regex.ALL_MATCH_REGEX.match('AlL') is not None + assert npbc_regex.ALL_MATCH_REGEX.match('ALL') is not None + +def test_delivery_regex(): + assert npbc_regex.DELIVERY_MATCH_REGEX.match('') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('a') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('1') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('1.') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('1.5') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('1,2') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('1-2') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('1;2') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('1:2') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('1,2,3') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('Y') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('N') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YY') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YYY') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YYYY') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YYYYY') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YYYYYY') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YYYYYYY') is not None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YYYYYYYY') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('NNNNNNN') is not None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('NYNNNNN') is not None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('NYYYYNN') is not None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('NYYYYYY') is not None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('NYYYYYYY') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('N,N,N,N,N,N,N') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('N;N;N;N;N;N;N') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('N-N-N-N-N-N-N') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('N N N N N N N') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YYYYYYy') is None + assert npbc_regex.DELIVERY_MATCH_REGEX.match('YYYYYYn') is None + + + +def test_regex_hyphen(): + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1-2') == ['1', '2'] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1-2-3') == ['1', '2', '3'] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1 -2-3') == ['1', '2', '3'] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1 - 2-3') == ['1', '2', '3'] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1- 2-3') == ['1', '2', '3'] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1') == ['1'] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1-') == ['1', ''] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1-2-') == ['1', '2', ''] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1-2-3-') == ['1', '2', '3', ''] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1,2-3') == ['1,2', '3'] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1,2-3-') == ['1,2', '3', ''] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('1,2, 3,') == ['1,2, 3,'] + assert npbc_regex.HYPHEN_SPLIT_REGEX.split('') == [''] \ No newline at end of file