Firstly the goal of this code is a little more complex than in this post and the other invoice questions are C#
Is there anything that just pops out as immeadiately unpythonic/wrong ?
I have a few questions that are written in the code in the comments pertaining to specific issues in the code. (Denoted by the '?' at the end) and I also have the following general questions:
Is my intent easy to understand from the code ?
Do I need to put in more comments/doc strings ?
Are there any style/implementation issues ?
How can the code be more pythonic ?
How can the code be more professional ?
Is there a much better way to solve that problem that is in some way totally different to the code I've written ?
Main loop (file:InvoiceHelp.py):
from InvoiceCreator import *
'''
InvoiceCreator is an the main class, talking input from the user (JobStructures) and
saving that in a list called 'Month.All_Jobs'. InvoiceCreator uses it's own methods
and those from HelperFunctions to store and eventually turn that data into an
invoice in PDF form using InvoiceAsPDF (which needs some re-writing). Information
stored on disc at startup is handled by ConfigManagerClass and instructions are
contained in Instructions.py
'''
CONFIGFILENAME = "/home/hp-350/Desktop/Computing M2/Python/Invoice Help 5/config.IH5"
if __name__ == '__main__':
running = True
while running:
invoice_creator = InvoiceCreator(CONFIGFILENAME)
instructions()
invoice_creator.main_function()
This is the meat of the code and is the InvoiceCreator class that the docstring (in InvoiceHelp.py) is taking about: HelperFunctions, JobStructures, Instructions and the rest will be after the following code.
InvoiceCreator.py
from sys import exit
import functools
from JobStructures import *
from Month import Month
# Docs recommend importing *
from fpdf import *
from InvoiceAsPDF import *
from ConfigManagerClass import *
from HelperFunctions import *
from Instructions import *
class InvoiceCreator:
'''
Takes simple user input (see process_input()) and information stored in the
config file (see ConfigManager) to generate simple data about how many
hours where worked on each calender day and whether on that day I gt paid a
travel allowance. Thsi data can be manipulated also through various one
letter commands eg, saved, loaded, deleted, replaced etc. This data can
then be used to generate an invoice (stored in Month.All_Jobs) which can be
subsequent turned into a pdf. See instructions.py.
'''
# Some of the following statements worry me, (just calling a class definition)
# I Can do this but some how Month() here doesn't feel right ?
# There will be only on Month() and no objects of its type will be created
invoices_directory = ""
CONFIGFILENAME = ""
Month()
def __init__(self, filename):
clear_screen()
print("\n --------------- Python Invoice Help ---------------\n")
InvoiceCreator.CONFIGFILENAME = filename
Month.init()
Month.choose_this_or_previous()
successful_load = False
self.daily_job_data = DailyJobData()
self.__data_to_from_config_file(filename)
self.current_date = question_range_reply("\nStart Date is (followed by RETURN/ENTER) ",
int, range(1, Month.last_day + 1))
self.accepting_user_data = True
# Used in process_input, the keys (of the dict) being possible user input
self.FUNCTION_DICTIONARY = {
"q" : self.__quit_both_loops,
"p" : self.__print_this_invoice,
"c" : clear_screen,
"d" : functools.partial(self.__forward, 1),
"b" : functools.partial(self.__forward, -1),
"t" : functools.partial(self.__forward, 3),
"w" : functools.partial(self.__forward, +7),
"r" : functools.partial(self.__forward, -7),
"j" : self.__new_ref_and_TA,
"i" : instructions,
"e" : self.__delete,
"k" : self.__kill_all,
"l" : self.__loading,
"s" : self.__save_invoice_data,
"f" : self.__convert_to_pdf,
"#" : self.__change_invoice_number
}
def __data_to_from_config_file(self, filename):
cm = ConfigManager()
config_data = cm.get_config_data(filename)
self.daily_job_data.job_consts.set_job_consts(config_data.job_consts)
if JobConsts.consts_are_empty(config_data.job_consts):
cm.save_new_job_consts_to_config(filename, self.daily_job_data.job_consts)
InvoiceCreator.invoices_directory = config_data.destination_path
self.invoice_number = config_data.invoice_number
self.pay_rate = config_data.pay_rate
def __print_this_invoice(self):
if not Month.All_Jobs:
print("\n-------------- No Data in the Month to print --------------")
return
list_of_days_of_work = self.__generate_invoice_contents_by_date()
print()
for day in list_of_days_of_work[:-1]:
print(day)
print(f"\nNumber of Days Worked: {(len(list_of_days_of_work) - 2)}."
f" Grand Total: {float(str(*list_of_days_of_work[-1:])):.2f}"
f" Invoice number: {self.invoice_number}")
def __new_ref_and_TA(self):
ref = input("Input new job reference: ")
ta = question_range_reply("Input new travel_allowance: ", float, [0, 300])
self.daily_job_data.job_consts.change_ref_and_ta(ref, ta)
def __change_invoice_number(self):
self.invoice_number = question_range_reply("Input new invoice number (min 3000)",
int, range(3000, 4000))
def __generate_invoice_contents_by_date(self):
'''
Final element of returned list is the grand_total
f'{a:.2f}'
1.00 hrs worked plus £50.00 travel allowance
1000.00 hrs worked only
The above lines are different lengths and SPACE_STR takes care of that
SPACE_STR not technicaly a constant but for each loop it is
see comment "Even out the number of spaces bewteen different lines"
in function
'''
return_list = ["Date \t Description \t Job Ref \t Cost \t"]
grand_total = 0.0
if self.daily_job_data.job_consts.travel_allowance == None:
travel_al = 0
else:
travel_al = float(self.daily_job_data.job_consts.travel_allowance)
for ordered_day in range(1, (Month.last_day + 1)):
for date, invoice_data in Month.All_Jobs.items():
if ordered_day == date:
# Even out the number of spaces bewteen different lines, so it looks pretty
num_of_spaces = (len(f"£{travel_al:.2f} travel allowance\t") -
(len(f"{invoice_data.job_vars.hours:.2f}") - len("1.00")))
SPACE_STR = " " * num_of_spaces
total = float(invoice_data.job_vars.hours * self.pay_rate)
if not invoice_data.job_vars.TA_for_this_day:
return_list.append(f"{date}\t{invoice_data.job_vars.hours:.2f} hrs worked only {SPACE_STR}\t"
f"{self.daily_job_data.job_consts.reference}\t £{total:.2f}\t")
else:
total += float(self.daily_job_data.job_consts.travel_allowance)
return_list.append(f"{date}\t{invoice_data.job_vars.hours:.2f} hrs worked plus "
f"£{travel_al:.2f} travel allowance\t"
f"{self.daily_job_data.job_consts.reference}\t £{total:.2f}\t")
grand_total += total
return_list.append(grand_total)
return return_list
def __forward(self, number_of_days):
if number_of_days < 0:
if self.current_date + number_of_days < 1:
return
elif self.current_date + number_of_days > Month.last_day:
return
self.current_date += number_of_days
def __delete(self):
try:
del Month.All_Jobs[self.current_date]
except KeyError:
print(f"\nNo data on {self.current_date} of {Month.month}")
else:
print(f"\nDATA DELETED on {self.current_date} of {Month.month}")
def __kill_all(self):
print("-------------- KILLING ALL DATA ! --------------")
if question_user("Do you want to save first (y/n)", 'y'):
self.__save_invoice_data()
Month.All_Jobs.clear()
self.daily_job_data.job_consts.reset()
def __loading(self) -> bool:
'''
Having given the user a choice as to which .ih5 file to load as job data:
An .ih5 file looks like this and loading() assigns the job constants from the first line,
Month from the third and then the job data from subsequent lines of the file
(Each of those lines starting with 'Date:'):
Name: Former ZFG Unit Job address: Blossom Ind Est City: Cirencester SN20 4GH Ref: AAA 567 Travel_allowance: 25
MONTH: March
Date:20 100.00 hrs worked only AAA 567 £2500.00
Date:28 50.00 hrs worked only AAA 567 £1250.00
If all the data is successfully allocated the True is returned else False
'''
if Month.All_Jobs:
print("-------------- LOAD WILL OVER WRITE ALL DATA --------------")
if question_user("Do you want to save first (y/n) ", 'y'):
self.__save_invoice_data()
print("\n-------------- Available Invoice Help files: -------------- ")
list_of_IH_files = list_of_files_in_dir(InvoiceCreator.invoices_directory, ".ih5")
if not list_of_IH_files:
print("-------------- There are no .ih5 files to load --------------")
return False
for idx, a_file in enumerate(list_of_IH_files):
print(f"{(idx + 1)} {a_file}\n")
choice = question_range_reply("Select file number or just press 0 followed by RETURN to quit this option: ",
int, range(0, idx + 2))
if (choice == 0):
print("-------------- Leaving IH files to be read without doing anything --------------")
return False
filedata = read_strings_from_file((InvoiceCreator.invoices_directory
+ list_of_IH_files[(choice - 1)]))
# I could have used a dict here, (str:function) but origionally
# there were only two function calls not four ?
for line in filedata:
if 'INVOICE:' in line:
self.__assign_invoice_num_from_str(line)
if 'Name:' in line:
self.__assign_job_consts_from_str(line)
if 'MONTH:' in line:
self.__assign_month_from_str(line)
pass
if 'Date:' in line:
self.__assign_job_data_from_str(line, self.daily_job_data.job_consts.reference,
self.daily_job_data.job_consts.travel_allowance)
return True
def __assign_invoice_num_from_str(self, inp_str):
self.invoice_number = int(inp_str.replace("INVOICE:", "").strip())
def __assign_job_consts_from_str(self, inp_str):
self.daily_job_data.job_consts.consts_from_str(inp_str)
def __assign_month_from_str(self, inp_str):
new_month = inp_str.replace("MONTH:", "").strip()
if new_month != Month.month:
# again this could be a really bad idea letting the user change month in this way
if question_user(f"Do you want to replace {Month.month} with {new_month} (y/n)", 'y'):
Month.month = new_month
def __assign_job_data_from_str(self, inp_str, self_job_ref, self_job_travel_allowance):
'''
inp_str will look something like either of the two lines below:
Date:20 2.00 hrs worked plus £50.00 travel allowance LU0 3198 £100.00
Date:21 2.00 hrs worked only
From these strings __assign_job_data_from_str assigns values to
daily_job_data.job_consts & daily_job_data.job_vars, by breaking up the string.
It also checks whether you want to assign data if it's from a different job.
It returns success = True if data is assigned to daily_job_data, and False
if there's some kind of failure, i.e. the user doesn't want the data from a different
job.
'''
# Is there a better way to do this than assign all these sub strings.
# Doesn't feel very Pythonic..?
inp_str_no_date_word = inp_str.replace("Date:", "")
job_date_str = inp_str_no_date_word.split('\t',1)[0]
str_no_date = inp_str_no_date_word.split('\t',1)[1]
hours_str = str_no_date.split("hrs worked")[0].strip()
ta_and_rest_str = str_no_date.replace(f"{hours_str} hrs worked ", "")
ta_today = True
ref_and_total_str = ""
if "only" in ta_and_rest_str:
ta_today = False
ref_and_total_str = ta_and_rest_str.replace("only", "")
elif "travel allowance" in ta_and_rest_str:
intermidiate_str = ta_and_rest_str.replace("plus £", "").strip()
ta_str = intermidiate_str.split("travel allowance")[0].strip()
ref_and_total_str = intermidiate_str.replace(f"{ta_str} travel allowance","").strip()
else:
print("There's trouble getting job data, format error in file maybe?")
ref_str = ref_and_total_str.split('\t')[0].strip()
total_str = ref_and_total_str.split('\t',1)[1].strip()
total_str_b = total_str.replace("£", "")
self.daily_job_data.add_days_work(int(job_date_str), float(hours_str), ta_today)
Month.add_a_day_of_work_to_month(int(job_date_str), self.daily_job_data)
def __save_invoice_data(self):
if not Month.All_Jobs:
print("\n-------------- No Data in the Month to save --------------")
return
invoice_data = [f"INVOICE: {self.invoice_number}"]
invoice_data.append(str(self.daily_job_data.job_consts))
invoice_data.append(f"MONTH: {Month.month}")
list_of_days_of_work = self.__generate_invoice_contents_by_date()
for day in list_of_days_of_work[1:-1]:
invoice_data.append(('Date:' + day))
filename = self.__generate_filename("", ".ih5")
write_to_file(filename, invoice_data, "w")
def __convert_to_pdf(self):
if not Month.All_Jobs:
print("\n-------------- No Data in the Month to pdf --------------")
if not question_user("Do you want to PDF a blank invoice (y/n)?", 'y'):
return
# create filename with job name month and invoice number, which is got from config file
print("Converting to pdf, saving as .ih5 file first")
self.__save_invoice_data()
print("Saved .ih5 file")
pdf = PDF(orientation = 'P', unit = 'mm', format = 'A4')
pdf.add_font('DejaVu', '', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', uni=True)
pdf.add_font('DejaVu-Bold', '', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', uni=True)
pdf.add_page()
pdf.titles(self.invoice_number, self.daily_job_data, self.__generate_invoice_contents_by_date(), Month)
# Used for testing purposes. Anything with Invoice at the beginning is Not a test file.
# INV_STR = ""
INV_STR = "Invoice "
filename = self.__generate_filename(INV_STR, ".pdf")
print('filename at convert_to_pdf:',filename)
pdf.output(filename,'F')
print('type(self.invoice_number):',type(self.invoice_number))
self.invoice_number += 1
max_invoice_num = self.__max_invoice_num_from_files(InvoiceCreator.invoices_directory)
lin_str = f"Last Invoice Number:{max_invoice_num}\n"
write_to_file(InvoiceCreator.CONFIGFILENAME, [lin_str], "Last Invoice Number", 'i')
def __max_invoice_num_from_files(self, path):
list_of_files = list_of_files_in_dir(path)
# search for files with "Invoice" until second space
max_invoice_num = self.invoice_number
for a_file in list_of_files:
if "Invoice" in a_file:
invoice_str = a_file.split(" ", 2)[1]
try:
invoice_num_from_files = int(invoice_str)
except Exception:
pass
else:
if invoice_num_from_files > max_invoice_num:
max_invoice_num = invoice_num_from_files
return max_invoice_num
def __generate_filename(self, TEST_STR, file_ext):
'''
The TEST_STR parameter will be empty when running tests on the software.
After testing it'll contain the value "Invoice " See note at call site
'''
return InvoiceCreator.invoices_directory + TEST_STR + str(self.invoice_number) + " " + \
self.daily_job_data.job_consts.job_name + " " + self.daily_job_data.job_consts.job_city + " " + \
str(Month.month) + file_ext
def process_input(self, month : Month) -> bool:
'''
This function extracts either a command letter, a number of hours
either with or without a travel allowance.
Correct inputs are (should be case insensitive):
"8.75n" (8.75 hours with no travel allowance)
"68" (68 hours with travel allowance, a lot of hours for 1 day !)
"c" (clear the screen)
"D" (advance an extra day)
"3N" (3 hours with no TA)
'''
already_str = ""
if ((self.current_date in Month.All_Jobs) and (Month.All_Jobs[self.current_date].job_vars.hours != 0)):
already_str = f"CURRENTLY: {Month.All_Jobs[self.current_date].job_vars.hours}: "
hours_or_command = input(f"\n Input number of hours worked on the {self.current_date} "
f"of {Month.month}, at {self.daily_job_data.job_consts.job_name}: {already_str}")
if len(hours_or_command) > 8:
if not question_user(" That's a lot of info are you sure (Y/N) ? ", 'y'):
return
input_str_l = hours_or_command.lower()
hours = 0
hours_inputted = False
TA_for_today = True
# This should have been done with a regex to test if input
# was correct but couldn't get it to work. Something like
# ^(?:0|[1-9]\d+|)?(?:.?\d{0,2})+^([a-z])$
try:
hours = float(input_str_l)
except ValueError:
if 'n' in input_str_l:
try:
hours = float(input_str_l.replace("n", ""))
except ValueError:
print("\n There's been an input error, too many characters or mangled number before/after 'n'\n")
return
else:
TA_for_today = False
hours_inputted = True
elif len(input_str_l) == 1:
command_executed = False
for key in self.FUNCTION_DICTIONARY:
if (key in input_str_l) and not command_executed:
self.FUNCTION_DICTIONARY[key]()
command_executed = True
break
if not command_executed:
print("\nThis letter isn't a command.\n")
return self.accepting_user_data
else:
print("\nInput Error!\n")
else:
hours_inputted = True
if hours > 24:
print("\n-------------- That's a LOT of HOURS, JUST letting you KNOW --------------")
if hours_inputted:
self.__add_daily_data_to_month(hours, TA_for_today)
return self.accepting_user_data
def __add_daily_data_to_month(self, hours, TA_for_today):
if hours < 0.0:
print(" ------------------ CAN'T HAVE NEGATIVE HOURS ------------------ ")
return
elif hours == 0.0:
if not question_user("Are you sure you want 0 hours on this day ? (y/n)", 'y'):
return
if (self.daily_job_data.job_consts.travel_allowance == None or
(int(float(self.daily_job_data.job_consts.travel_allowance))) == 0):
TA_for_today = False
self.daily_job_data.add_days_work(self.current_date, hours, TA_for_today)
Month.add_a_day_of_work_to_month(self.current_date, self.daily_job_data)
if self.current_date < Month.last_day:
self.current_date += 1
# Make process_input 'public' and call that from IH5.py ?
def main_function(self):
while self.process_input(Month.month):
continue
JobStructures.py these are the units that are essentially a day of work in a month, and lots of them together are stored in Month.All_Jobs to form an invoice (which can then be turned into a PDF).
# ----------------------- See below post for init issues -----------------------
# https://stackoverflow.com/questions/55800218/setting-default-empty-attributes-for-user-classes-in-init
from HelperFunctions import rsetattr, rgetattr, question_user
# I thought about using builder pattern for some of the objects of
# these classes (but decided against it). Any thoughts ?
# https://stackoverflow.com/questions/11977279/builder-pattern-equivalent-in-python
class JobConsts:
'''
Each Job has a job reference, a job address and¸travel allowance. Within a
months billing period these don't change. Sometimes we may do
another job at the same site, the address would be the same but the job
reference wuld be different. Also if the price of fuel goes up the travel
allowance may change to reflect that, but again during the month that
value is fixed.
'''
NUMBER_OF_CONSTS = 5 # job_name, job_address, job_city, reference, travel_allowance
TEXT_TO_MEMBER_DICT = {
"Name:" : 'job_name',
"Job address:" : 'job_address',
"City:" : 'job_city',
"Ref:" : 'reference',
"Travel_allowance:" : 'travel_allowance'
}
# Builder patter for these (I decided against but have a nagging doubt) ?
def __init__(self):
self.job_name = ""
self.job_address = ""
self.job_city = ""
self.reference = ""
self.travel_allowance = None
def consts_are_empty(external_job_consts):
condition = ((external_job_consts.job_name == "")
and (external_job_consts.job_address == "")
and (external_job_consts.job_city == "") and (external_job_consts.reference == "")
and (external_job_consts.travel_allowance == None))
return condition
def reset(self):
self.__init__()
# According to this post:
# https://stackoverflow.com/questions/2144988/python-multiple-calls-to-init-on-the-same-instance
# it's OK to do this. It's certainly DRY but feels a bit weird maybe ?
def set_job_consts(self, external_job_consts):
if JobConsts.consts_are_empty(external_job_consts):
self.job_name = input("\nJob name is (followed by RETURN/ENTER) ")
self.job_address = input("\nSite Address for this job (No City)(followed by RETURN/ENTER) ")
self.job_city = input("\nCity/Town where job is (followed by RETURN/ENTER) ")
self.reference = input("\nJob REFERENCE is (followed by RETURN/ENTER) ")
self.travel_allowance = float(input("\nTravel Allowance for this job is (followed by RETURN/ENTER) "))
else:
self.job_name = external_job_consts.job_name
self.job_address = external_job_consts.job_address
self.job_city = external_job_consts.job_city
self.reference = external_job_consts.reference
self.travel_allowance = external_job_consts.travel_allowance
print(f"\n-------------- Job_consts load from config file. This was a PREVIOUS JOB --------------:\n"
f"{self}\n")
def change_ref_and_ta(self, new_ref, new_ta):
if new_ref != self.reference:
if question_user(f"New job reference ({new_ref}) is different from "
f"current job ref ({self.reference})"
f", replace (y/n): ", 'y'):
self.reference = new_ref
if new_ta != self.travel_allowance:
if question_user(f"New travel allowance ({new_ta}) is different from "
f"current travel allowance ({self.travel_allowance})"
f", replace (y/n): ", 'y'):
self.travel_allowance = new_ta
def consts_from_str(self, inp_str):
list_of_job_consts = inp_str.split(" ") # See JobConsts.__str__
list_of_job_consts = [s.strip(' ') for s in list_of_job_consts]
for possible_const in list_of_job_consts:
for string_key in JobConsts.TEXT_TO_MEMBER_DICT:
if (string_key in possible_const):
new_job_const = possible_const.replace(string_key, "").strip()
rsetattr(self, JobConsts.TEXT_TO_MEMBER_DICT[string_key], new_job_const)
# Not sure that this is neccessary ?
def __iter__(self):
return iter(self.__dict__)
def __str__(self):
SPACE = " "
return (f"{SPACE:<0}Name: {self.job_name}{SPACE:<3}Job address: {self.job_address}"
f"{SPACE:<3}City: {self.job_city}{SPACE:<3}Ref: {self.reference}"
f"{SPACE:<3}Travel_allowance: {self.travel_allowance}")
class JobVariables:
'''
Each month will have a number of days that I worked on a particular job (as
denoted by the JobConsts). Each data that I worked will have the number of hours
I worked at that job on that date. And whether or not I'll be paid a travel
allowance. Sometimes the job is local or I'm using a company vehicle and so
I don't get paid a T.A. for that day.
'''
def __init__(self, date = 0, hrs = 0, TA_yes_no = True):
self.TA_for_this_day = TA_yes_no
self.date = date
self.hours = hrs
def set_job_vars(self, current_date , hours, TA_yes_no):
self.TA_for_this_day = TA_yes_no
self.date = current_date
self.hours = hours
def __str__(self):
SPACE = " "
return (f"{SPACE:<3}Job date: {self.date}"
f"{SPACE:<3}Hours: {self.hours}{SPACE:<3}"
f"{SPACE:<3}TA_for_this_day: {self.TA_for_this_day}")
class DailyJobData:
'''
DailyJobData form the 'units' that are kept in Month.All_Jobs and
make up one invoice.
'''
def __init__(self):
self.job_consts = JobConsts()
self.job_vars = JobVariables()
def __iter__(self):
return iter(self.__dict__)
def __str__(self):
return f"{self.job_consts}{self.job_vars}"
def add_days_work(self, current_date, hours, TA_yes_no):
self.job_vars.set_job_vars(current_date, hours, TA_yes_no)
print(f"{self.job_consts.job_name} : {self.job_consts.reference} :{self.job_vars}")
Month.py
from JobStructures import *
from datetime import datetime, timedelta
from calendar import month_name, monthrange
from HelperFunctions import question_range_reply
import copy
class Month():
All_Jobs = {}
month = None
today = None
current_month = None
current_month_number = 0
previous_month = None
last_day = None
year = None
# Because there isn't an instance of Month() I'm not using
# __init__() again this all works but it feels a bit odd.
# is this pythonic, just using a class without an instance ?
def init():
Month.today = datetime.now()
Month.current_month = Month.today.strftime("%B")
Month.current_month_number = int(Month.today.strftime("%m"))
Month.previous_month = month_name[Month.today.month -1] or month_name[-1]
Month.month = Month.current_month
Month.year = Month.today.year
def choose_this_or_previous():
choice = question_range_reply(f"Are you entering data for (T)his month,"
f" {Month.current_month}, or the (P)revious month,"
f" {Month.previous_month}: ", str, ['t', 'p'])
if choice == "p":
Month.month = Month.previous_month
if Month.current_month_number == 1:
Month.last_day = 31
else:
Month.last_day = monthrange(Month.year, (Month.current_month_number - 1))[-1]
else:
Month.last_day = monthrange(Month.year, Month.current_month_number)[-1]
def add_a_day_of_work_to_month(date , a_days_job_data):
if not Month.All_Jobs:
# Deepcopy because different versions of
# a_days_job_data are the same object, otherwise
# Month.All_Jobs values will all be the same, just
# with different keys
Month.All_Jobs[date] = copy.deepcopy(a_days_job_data)
elif date not in Month.All_Jobs:
Month.All_Jobs[date] = copy.deepcopy(a_days_job_data)
else: #if there's two jobs on the same date
print("There are two jobs on this date:")
print(f"1) {a_days_job_data}\n")
print(f"2) {Month.All_Jobs[date]}")
choice = question_range_reply("Which data do you want for this date: ", int,
range(1, 3))
if choice == 1:
Month.All_Jobs[date] = copy.deepcopy(a_days_job_data)
HelperFunctions.py and Instructions.py
from os import listdir, system
from os.path import isfile, join, exists
import functools
import fileinput
import sys
# --------------------------------------------------------------------------
# These two functions created by unutbu
# https://stackoverflow.com/questions/31174295/getattr-and-setattr-on-nested-subobjects-chained-properties
def rsetattr(obj, attr, val):
pre, _, post = attr.rpartition('.')
return setattr(rgetattr(obj, pre) if pre else obj, post, val)
# using wonder's beautiful simplification: https://stackoverflow.com/questions/31174295/getattr-and-setattr-on-nested-objects/31174427?noredirect=1#comment86638618_31174427
def rgetattr(obj, attr, *args):
def _getattr(obj, attr):
return getattr(obj, attr, *args)
return functools.reduce(_getattr, [obj] + attr.split('.'))
# --------------------------------------------------------------------------
def clear_screen():
system('clear')
def write_to_file(filename, text_to_be_written, search_pattern = "", mode ='w'):
'''
text_to_be_written should be in list form, otherwise it'll
write a line as a char per line.
If mode is 'i' i.e insert it's expected that text_to_be_written should
only be 1 element. It replaces the with the search_pattern in with
text_to_be_written.
'''
if mode =='i':
try:
for line in fileinput.input(filename, inplace=1):
if search_pattern in line:
line = text_to_be_written[0]
sys.stdout.write(line)
print(f"{search_pattern} replaced with {text_to_be_written[0]} in {filename}")
return
except IOError as e:
print(f"Error {e.errno}, {e.strerror}, has occured writing to file :{filename}")
except Exception as err:
print(f"A general error has occured writing to file:{filename}.")
if mode == "a":
print("@write_to_file. APPEND MODE SELECTED")
try:
with open(filename, mode) as wtfile:
for a_line in text_to_be_written:
wtfile.write(str(a_line + '\n'))
print(f"Saved {filename}\n")
except IOError as e:
print(f"Error {e.errno}, {e.strerror}, has occured writing to file :{filename}")
except Exception as err:
print(f"A general error has occured writing to file:{filename}.")
else:
return
print("Nothing has been saved")
def list_of_files_in_dir(directory_name = ".", file_ext = "" ) -> list:
if not exists(directory_name):
print(f"@ list_of_files_in_dir(): {directory_name} doesn't exist. Returning empty list")
return []
if isfile(directory_name):
print(f"@ list_of_files_in_dir(): {directory_name} is a file not a directory. Returning empty list")
return []
return ([item for item in listdir(directory_name)
if (isfile(join(directory_name, item)) and (file_ext in item))])
# Should these else statements raise an exception instead of returning an empty list ?
def read_strings_from_file(filename):
'''
Returns a list of strings (returnList) from filename, whatever the
form of data contained within, this if it's all numeric data in the
file you'll still get a list of strings.
'''
returnlist = []
if not exists(filename):
print(f"@read_strings_from_file(). {filename} doesn't exist. Returning empty list.")
return []
if not isfile(filename):
print(f"@read_strings_from_file(). {filename} isn't a file. Returning empty list.")
return []
try:
with open(filename) as fileobj:
for line in fileobj:
returnlist.append(line)
return returnlist
except IOError as e:
print(f"Error {e.errno}, {e.strerror}, has occured reading from file :{filename}. Returning empty list.")
except Exception as err:
print(f"A general error has occured reading from file:{filename}"
f" It's {err.errno}, {err.strerror}. Returning empty list.")
return []
# Should these else statements raise an exception instead of returning an empty list ?
def question_range_reply(question_str, type_paramT, range_param):
'''
Take input from the user (which should within a range, range_param)
and returns a value (of type type_param).
range_param can be in the form of a list. If user inputs a value
outside of the range, an error message is printed and the user asked
to input again.
'''
while(True):
try:
reply = type_paramT(input(question_str).strip())
except ValueError:
print(f"There's a problem with input, illegal charcters ?")
else:
if type(reply) == float:
if ((reply >= float(range_param[0])) and
(reply < float(range_param[-1]))):
return reply
if type(reply) == str:
reply = reply.lower()
if reply in range_param:
return reply
print(f"Input out of range, should be {range_param[0]} to {range_param[-1]}")
def question_user(question_str, reply_char) -> bool:
'''
A simpler version of the prevoius function
'''
reply = input(question_str)
return reply.strip().lower() == reply_char
Instructions:-
INSTRUCTIONS = "\n --------------- Python Invoice Help Instructions ---------------\n"\
"(Q)uit, (N)o travel allowance for that day, skip a (D)ay, skip (T)hree days, "\
"skip a (W)eek go (B)ack a day\n(R)etreat a week, (P)rint current job, "\
"(E)rase current date, new (J)ob Ref & Travel Allowance, (I)nformation, "\
"\n(C)lear screen, (K)ill all data, (S)ave, (L)oad, Convert to PD(F), Change invoice (#) number\n"
# Should I prefer free functions as opposed to class methods ?
# Therefore i've moved it outside of InvoiceCreator, is this right ?
def instructions():
print(INSTRUCTIONS)
InvoiceAsPDF.py I apologise for the quality of code in this module. I had a deadline to meet and just had to get something working. The comments detail some of the errors I've made. If there are aspects I've missed in terms of bad code/design, please let me know.
from fpdf import *
from Month import Month
from HelperFunctions import question_range_reply
# The following PDF class is terribly written, it was a case of
# getting something working as quickly as possible and I haven't had
# the time to refine it. Major problems as I see it:-
# Separation of concerns, it's tightly coupled to InvoiceCreator,
# It could be broken down into smaller functions, making it easier to read.
# I've included it for completeness, and perhaps it'll serve as a good
# example of how not to write a class, but sometimes 'it gets dirty'...
# I've peppered the following code with my own critique, if there's other
# points to be made, please do so..
class PDF(FPDF):
def titles(self, invoice_number, invoice, days_worked, month):
dw_len = len(days_worked) - 2 # grand total and the table titles
if dw_len < 1:
print("Not enough data going to PDF for an invoice !")
return
# Working out the spaceings could be done elsewhere
PAGE_WIDTH_MM = 210
client_n_address = ["Bobs Construction (North West) Ltd,", "4 Springton Road,", "Marksbury, Mk12 9DQ"]
banner_height18 = 18
banner_height12 = 12
banner_height13 = 13
banner_height15 = 15
spacing = 0
spacing2 = 0
spacings = (4, 6, 16, 27)
spacings2 = (8, 11, 16, 23)
possible_lengths = (21, 14, 7, 0)
for idx, l in enumerate(possible_lengths):
if dw_len >= l:
spacing = spacings[idx]
spacing2 = spacings2[idx]
break
ypos = 10
self.set_xy(0.0, ypos)
self.set_font('DejaVu-Bold', '', 18)
self.set_text_color(0, 0, 0)
self.cell(w = 210.0, h = banner_height18, align = 'C', txt = "Loz Lamarais", border = 0)
ypos += spacing2
self.set_xy(0.0, ypos)
self.set_font('DejaVu', '', 12)
self.cell(w = 210.0, h = banner_height12, align = 'C', txt = "Lozminda Lamarais: 10 Conneticette Street, Birmingham, B1 4TQ.", border = 0)
ypos += banner_height12 - 6
self.set_xy(0.0, ypos)
self.cell(w = 210.0, h = banner_height12, align = 'C', txt = "Tel: 07955 565656", border = 0)
ypos += banner_height12
self.set_font('DejaVu-Bold', '', 12)
self.set_xy(0.0, ypos)
self.cell(w = 210.0, h = banner_height12, align = 'C', txt = "My References: Nat Ins MN 56 78 78B, UTR 46574 585484 ", border = 0)
ypos += spacing2
self.set_xy(0.0, ypos)
self.set_font('DejaVu-Bold', '', 18)
self.cell(w = 210.0, h = banner_height18, align = 'C', txt = "INVOICE", border = 0)
ypos += spacing2
self.set_xy(10.0, ypos)
self.set_font('DejaVu-Bold', '', 13)
self.cell(w = 10.0, h = banner_height13, align = 'L', txt = "To:", border = 0)
self.set_xy(20.0, ypos)
ypos_to = ypos
self.set_font('DejaVu', '', 13)
for line in client_n_address:
self.cell(w = 100.0, h = banner_height13, align = 'L', txt = line , border = 0)
ypos += banner_height13 - 8
self.set_xy(20.0, ypos)
ypos_to += 5
self.set_xy(135.0, ypos_to)
self.set_font('DejaVu-Bold', '', 12)
self.cell(w = (70.0 / 3), h = ((banner_height13 / 3) + 2), align = 'C', txt = "Number:", border = 1, ln = 0)
self.cell(w = (70.0 / 3), h = ((banner_height13 / 3) + 2), align = 'C', txt = str(invoice_number), border = 1, ln = 0)
self.set_xy(135.0, (ypos_to + 8))
self.set_font('DejaVu-Bold', '', 8)
self.cell(w = (70.0 / 3), h = ((banner_height13 / 3) + 2), align = 'C', txt = "Issue Date :", border = 1)
self.set_font('DejaVu-Bold', '', 6)
self.cell(w = (70.0 / 3), h = ((banner_height13 / 3) + 2), align = 'C',
txt = (str(Month.last_day) + " " + Month.month + " " + str(Month.year)), border = 1, ln = 0)
ypos += spacing # TODO this can be a spacer depending on size of days worked
self.set_font('DejaVu-Bold', '', 15)
self.set_xy(0.0, ypos)
description = "For work carried out at " + invoice.job_consts.job_name + ", " + invoice.job_consts.job_address
description2 = invoice.job_consts.job_city
description3 = "Month ending " + str(Month.last_day) + " " + Month.month + " " + str(Month.year)
self.cell(w = 210, h = ((banner_height13 / 3) + 2), align = 'C', txt = description, border = 0, ln = 1)
self.cell(w = 210, h = ((banner_height13 / 3) + 2), align = 'C', txt = description2, border = 0, ln = 1)
self.cell(w = 210, h = ((banner_height13 / 3) + 2), align = 'C', txt = description3, border = 0, ln = 1)
ypos += 20 + spacing
self.set_xy(0.0, ypos)
# create table data
table_widths = (18, 100, 32, 32)
table_alignments = ('C', 'L', 'C', 'R')
cell_start = (0, 0, 0, 1)
self.set_font('DejaVu-Bold', '', 11)
# This table could be a separate function/method
half_length = True # To add 6.5 to ypos ie (6, 7, 6, 7)
for day in days_worked[:-1]:
self.x = int((PAGE_WIDTH_MM - sum(table_widths)) / 2)
pos = 0
table_idx = 0
ypos += (5 + int(half_length))
# ypos += 6
half_length = not half_length
for index, char in enumerate(day):
if char == '\t':
text = day[pos:index]
self.cell(w = table_widths[table_idx], h = ((banner_height12 / 3) + 1),
align = table_alignments[table_idx], txt = text, border = 1, ln = cell_start[table_idx])
pos = index + 1
table_idx += 1
self.set_font('DejaVu', '', 10)
self.set_xy(0, ypos)
self.set_font('DejaVu-Bold', '', 13)
tot_str = f"Total: £{float(str(*days_worked[-1:])):.2f}"
self.cell(w = 210, h = 6, align = 'C', txt = tot_str, border = 0, ln = 1)
# This should be outside class and passed as a parameter
expenses = question_range_reply("Any expenses? If input is 0 nothing will appear on pdf ",
float, [0, 5000.00])
self.x = 0
if expenses != 0:
temp_text = f"Expenses: £{expenses:.2f}"
self.cell(w = 210, h = 6, align = 'C', txt = temp_text, border = 0, ln = 1)
self.y = 255
self.set_font('DejaVu', '', 11)
self.set_text_color(100, 20, 0)
self.cell(w = 200, h = (banner_height13 / 3) + 2, align = 'C',
txt = "Bank Details: Acc Name: L. Q. Lamarais, Acc Number ******** , Sort Code 00-00-00",
border = 0, ln = 1)
self.set_font('DejaVu', '', 12)
self.set_text_color(0, 0, 0)
self.cell(w = 210, h = (banner_height13 / 3) + 2, align = 'C', txt = "Please make cheques payable to L. Lamarais",
border = 0, ln = 1)
self.cell(w = 170, h = (banner_height13 / 3) + 2, align = 'R', txt = "Thank you", border = 0, ln = 1)
And finally the config file manager and the config file itself: ConfigManagerClass.py
from os import path
from JobStructures import *
from HelperFunctions import read_strings_from_file, write_to_file, question_range_reply
import sys
class ConfigReturnData:
def __init__(self):
self.job_consts = JobConsts()
self.invoice_number = 0
self.pay_rate = 0
self.destination_path = ""
class ConfigManager():
possible_jobs = []
def get_config_data(self, filename):
self.CONFIG_FILE_DICT = {
"Rate Of Pay:" : self.set_pay_rate,
"Destination Path:" : self.set_destination_path,
"Last Invoice Number:" : self.set_invoice_number,
"Name:" : self.add_job_to_possible_jobs
}
self.config_file_str = ""
self.return_data = ConfigReturnData()
self.load_config(filename)
return self.return_data
def load_config(self, filename):
config_file_data = read_strings_from_file(filename)
for line in config_file_data:
for key in self.CONFIG_FILE_DICT:
if (key in line):
self.config_file_str = line.replace(key,"").strip()
self.CONFIG_FILE_DICT[key]()
if len(ConfigManager.possible_jobs) > 0:
self.user_choose_job()
else:
self.return_data.job_consts = None
def save_new_job_consts_to_config(self, filename, job_consts):
write_to_file(filename, [f"{job_consts}"], mode = 'a')
def set_pay_rate(self):
try:
self.return_data.pay_rate = float(self.config_file_str)
except ValueError:
print("Illegal value load_pay_rate in config file, program terminating\n")
sys.exit(1)
else:
print('\nRate of Pay',self.return_data.pay_rate, end=" ")
def add_job_to_possible_jobs(self):
# Because 'Name:' was stripped off in calling loop
self.config_file_str = "Name: " + self.config_file_str
temp = JobConsts()
temp.consts_from_str(self.config_file_str)
ConfigManager.possible_jobs.append(temp)
def user_choose_job(self):
print()
for idx, job in enumerate(ConfigManager.possible_jobs):
print(f"({(idx + 1)}){job}")
choice = question_range_reply("\nIs this invoice for any of the preceding jobs, if input"
f" number else just press 0 plus RETURN to input new info: ",
int, range(0, (idx + 2)))
if choice != 0:
job_number = int(choice) - 1
self.return_data.job_consts.job_name = ConfigManager.possible_jobs[job_number].job_name
self.return_data.job_consts.job_address = ConfigManager.possible_jobs[job_number].job_address
self.return_data.job_consts.job_city = ConfigManager.possible_jobs[job_number].job_city
self.return_data.job_consts.reference = ConfigManager.possible_jobs[job_number].reference
self.return_data.job_consts.travel_allowance = ConfigManager.possible_jobs[job_number].travel_allowance
def set_invoice_number(self):
try:
self.return_data.invoice_number = int(self.config_file_str)
except ValueError:
print("Illegal value invoice_number in config file, program terminating\n")
sys.exit(1)
else:
print(f'Last Invoice Number :{self.return_data.invoice_number}\n' )
def set_destination_path(self):
path_str = self.config_file_str.strip()
if not path.exists(path_str):
print(f"Directory {path_str}, for invoices doesn't exist, program terminating (but perhaps i should do something about this)\n")
sys.exit(1)
else:
self.return_data.destination_path = path_str
config file
Rate Of Pay:20.00
Destination Path:/home/hp-350/Desktop/Invoices/
Last Invoice Number:5000
Name: Unit 6 Job address:Edward Court Industrial Estate City:Woking Ref:266 3198 Travel_allowance: 50
Name: BrownFields Community Centre Job address: Thicket Rd City: Bristol Ref: 299 3190 Travel_allowance: 40
Name: Former QED Building Job address: Cloud Way City: Cirencester Ref: 300 3200 Travel_allowance: 55.0
Name: Units 10-12 Job address: Sharps Close City: Swindon Ref: 500 3169 Travel_allowance: 35.0