Repository: regro/regolith

Issue:
helper: new expense adder
add an expense to the expenses collection

Discussion:
sbillinge: Great start!

This already needs a rebase I am afraid.  Please talk to Michael or Songsheng if you are not sure (don't try and do it yourself).

Also there are formatting errors in your yaml, just fyi....  

Please make sure you are able to run tests locally and doing that too.  Talk to someone if you need help.
AhmedS343: @sbillinge Sorry about the delay. Should I update schemas.py to reflect what is in the google doc?
AhmedS343: @sbillinge I updated my updater, but I just noticed that there seems to be a difference between the format of the yaml on the projecta log vs the slack channel. I used the slack channel's format, but please let me know if this needs to be changed. I will also make changes to itemized expenses (and possibly add even more arguments) to reflect the multiple purposes possible. 
sbillinge: looks like you need a rebase too.

Patch:
diff --git a/news/a_expense.rst b/news/a_expense.rst
new file mode 100644
index 00000000..7271c3d6
--- /dev/null
+++ b/news/a_expense.rst
@@ -0,0 +1,13 @@
+**Added:**
+ * an adder helper to add new expenses to the expenses collection
+
+**Changed:** None
+
+**Deprecated:** None
+
+**Removed:** None
+
+**Fixed:**
+  * fsclient.py dump_yaml function to ignore aliases
+
+**Security:** None
\ No newline at end of file
diff --git a/regolith/fsclient.py b/regolith/fsclient.py
index 7ff04a97..ee454f6c 100644
--- a/regolith/fsclient.py
+++ b/regolith/fsclient.py
@@ -72,6 +72,7 @@ def load_yaml(filename, return_inst=False, loader=None):
 def dump_yaml(filename, docs, inst=None):
     """Dumps a dict of documents into a file."""
     inst = YAML() if inst is None else inst
+    inst.representer.ignore_aliases = lambda *data: True
     inst.indent(mapping=2, sequence=4, offset=2)
     sorted_dict = ruamel.yaml.comments.CommentedMap()
     for k in sorted(docs):
diff --git a/regolith/helper.py b/regolith/helper.py
index 843522a7..5c127038 100644
--- a/regolith/helper.py
+++ b/regolith/helper.py
@@ -5,6 +5,7 @@ from regolith.helpers import a_proprevhelper as a_proprev
 from regolith.helpers import a_grppub_readlisthelper as a_gprl
 from regolith.helpers import a_projectumhelper as a_projectum
 from regolith.helpers import a_proposalhelper as a_proposal
+from regolith.helpers import a_expensehelper as a_expense
 from regolith.helpers import l_milestoneshelper as l_milestone
 from regolith.helpers import l_projectahelper as l_projecta
 from regolith.helpers import l_grantshelper as l_grants
@@ -19,6 +20,7 @@ HELPERS = {
     "a_grppub_readlist": (a_gprl.GrpPubReadListAdderHelper, a_gprl.subparser),
     "a_projectum": (a_projectum.ProjectumAdderHelper, a_projectum.subparser),
     "a_proposal": (a_proposal.ProposalAdderHelper, a_proposal.subparser),
+    "a_expense": (a_expense.ExpenseAdderHelper, a_expense.subparser),
     "l_milestones": (l_milestone.MilestonesListerHelper, l_milestone.subparser),
     "l_projecta": (l_projecta.ProjectaListerHelper, l_projecta.subparser),
     "l_grants": (l_grants.GrantsListerHelper, l_grants.subparser),
diff --git a/regolith/helpers/a_expensehelper.py b/regolith/helpers/a_expensehelper.py
new file mode 100644
index 00000000..b6bab7fd
--- /dev/null
+++ b/regolith/helpers/a_expensehelper.py
@@ -0,0 +1,216 @@
+"""
+Helper to add expenses.
+"""
+import datetime as dt
+import dateutil.parser as date_parser
+from dateutil.relativedelta import relativedelta
+import sys
+
+from regolith.helpers.basehelper import DbHelperBase
+from regolith.fsclient import _id_key
+from regolith.tools import (
+    all_docs_from_collection,
+    get_pi_id,
+)
+
+TARGET_COLL = "expenses"
+ALLOWED_TYPES = ["business", "travel"] # need to check all expense types.
+ALLOWED_STATI = ["submitted", "unsubmitted", "reimbursed"]
+
+
+def subparser(subpi):
+    subpi.add_argument("amount", help="expense amount",
+                       )
+    subpi.add_argument("name", help="A short name for the expense",
+                       default=None
+                       )
+    subpi.add_argument("purpose", help="A short description of expense purpose",
+                       default=None)
+
+    subpi.add_argument("-b", "--business", action='store_true',
+                       help="expense type is business. If not specified defaults to travel"
+                       )
+    subpi.add_argument("-y", "--payee",
+                       help="payee of the expense. defaults to sbillinge",
+                       default="sbillinge"
+                       )
+    subpi.add_argument("-g", "--grants", nargs="+",
+                       help="grant, or list of grants that cover this expense. Defaults to tbd"
+                       )
+    subpi.add_argument("-s", "--status",
+                       help=f"status, from {ALLOWED_STATI}. Default is unsubmitted",
+                       default='unsubmitted'
+                       )
+    subpi.add_argument("-w", "--where",
+                       help="Where the expense has been submitted"
+                       )
+    subpi.add_argument("-n", "--notes", nargs="+",
+                       help="List of notes for the expense. Defaults to empty list",
+                       default= []
+                       )
+    subpi.add_argument("-d", "--begin_date",
+                       help="Input begin date for this expense. "
+                            "In YYYY-MM-DD format. Defaults to today's date",
+
+                       )
+    subpi.add_argument("-e,", "--end_date",
+                       help="Input end date for this expense. "
+                            "In YYYY-MM-DD format. Defaults to today's date",
+                       )
+    return subpi
+
+
+class ExpenseAdderHelper(DbHelperBase):
+    btype = "a_expense"
+    needed_dbs = [f'{TARGET_COLL}', 'people', 'groups']
+
+    def construct_global_ctx(self):
+        """Constructs the global context"""
+        super().construct_global_ctx()
+        gtx = self.gtx
+        rc = self.rc
+        rc.pi_id = get_pi_id(rc)
+        rc.coll = f"{TARGET_COLL}"
+        if not rc.database:
+            rc.database = rc.databases[0]["name"]
+        gtx[rc.coll] = sorted(
+            all_docs_from_collection(rc.client, rc.coll), key=_id_key
+        )
+        gtx["all_docs_from_collection"] = all_docs_from_collection
+        gtx["float"] = float
+        gtx["str"] = str
+        gtx["zip"] = zip
+
+    def db_updater(self):
+        gtx = self.gtx
+        rc = self.rc
+        # dates
+        if rc.begin_date:
+            begin_date = date_parser.parse(rc.begin_date).date()
+        else:
+            begin_date = dt.date.today()
+        if rc.end_date:
+            end_date = date_parser.parse(rc.end_date).date()
+        else:
+            end_date = dt.date.today()
+        key = f"{str(begin_date.year)[2:]}{str(begin_date.strftime('%m'))}{rc.payee[0:2]}_{''.join(rc.name.casefold().split()).strip()}"
+        coll = self.gtx[rc.coll]
+        pdocl = list(filter(lambda doc: doc["_id"] == key, coll))
+        if len(pdocl) > 0:
+            raise RuntimeError(
+                "This entry appears to already exist in the collection")
+        else:
+            pdoc = {}
+        pdoc.update({'_id': key,
+                     'begin_date': begin_date,
+                     'end_date': end_date,
+                     })
+        # expense_type
+
+
+        if rc.business:
+            expense_type = 'business'
+        else:
+            expense_type = 'travel'
+        pdoc.update({'expense_type': expense_type})
+
+        if rc.grants:
+            percentages = [100/len(rc.grants) for i in rc.grants]
+
+
+        else:
+            percentages = [100]
+        pdoc.update({'grant_percentages':percentages,
+                     'grants': rc.grants})
+
+        if expense_type == 'travel':
+            pdoc.update({'itemized_expenses': [
+                {
+                    'date': begin_date,
+                    'purpose': 'registration',
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                },
+                {
+                    'date': begin_date,
+                    'purpose': 'home to airport',
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                },
+                {
+                    'date': begin_date,
+                    'purpose': 'flights',
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                },
+                {
+                    'date': begin_date,
+                    'purpose': 'airport to hotel',
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                },
+                {
+                    'date': begin_date,
+                    'purpose': 'hotel',
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                },
+                {
+                    'date': begin_date,
+                    'purpose': 'hotel to airport',
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                },
+                {
+                    'date': begin_date,
+                    'purpose': 'airport to home',
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                },
+                {
+                    'date': begin_date,
+                    'purpose': 'meals',
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                }
+                ]
+            })
+        else:
+            pdoc.update({'itemized_expenses': [
+                {
+                    'date': begin_date,
+                    'purpose': rc.purpose,
+                    'unsegregated_expense': 0,
+                    'segregated_expense': 0
+                }]
+            })
+        pdoc.update({'notes': rc.notes,
+                     'overall_purpose': rc.purpose,
+                     'payee': rc.payee,
+                     })
+        if rc.status == 'submitted':
+            submission_date = dt.date.today()
+
+        else:
+            submission_date = 'tbd'
+
+        pdoc.update({'reimbursements': [
+            {
+                'amount': 0,
+                'date': 'tbd',
+                'submission_date': submission_date,
+                'where': rc.where,
+            }
+            ]
+        })
+        pdoc.update({
+            'status': rc.status
+        })
+
+
+
+        rc.client.insert_one(rc.database, rc.coll, pdoc)
+
+        print(f"{key} has been added in {TARGET_COLL}")
+
+        return
diff --git a/regolith/schemas.py b/regolith/schemas.py
index 3fd638a8..4e0837f7 100644
--- a/regolith/schemas.py
+++ b/regolith/schemas.py
@@ -3377,6 +3377,15 @@ SCHEMAS = {
             "required": True,
             "type": "string",
         },
+        "begin_date": {
+            "description": "begin date in YYYY-MM-DD",
+            "anyof_type": ["string", "date"],
+        },
+        "end_date": {
+            "description": "end date in YYYY-MM-DD",
+            "anyof_type": ["string", "date"],
+
+        },
         "grant_percentages": {
             "description": "the percentage of the reimbursement amount to put "
                            "on each grant. This list must be the same length as"
@@ -3394,7 +3403,6 @@ SCHEMAS = {
             "description": "project or list of projects that this "
                            "presentation is associated with.  Should "
                            "be discoverable in projects collection",
-            "required": True,
             "anyof_type": ["string", "list"],
         },
         "payee": {
@@ -3409,17 +3417,23 @@ SCHEMAS = {
                 "schema": {
                     "day": {
                         "description": "Expense day",
-                        "required": True,
+                        "required": False,
                         "type": "integer",
                     },
+                    "date": {
+                        "description": "Expense date",
+                        "required": False,
+                        "anyof_type": ["string", "date"],
+
+                    },
                     "month": {
                         "description": "Expense month",
-                        "required": True,
+                        "required": False,
                         "anyof_type": ["string", "integer"],
                     },
                     "year": {
                         "description": "Expense year",
-                        "required": True,
+                        "required": False,
                         "type": "integer",
                     },
                     "purpose": {
@@ -3447,6 +3461,37 @@ SCHEMAS = {
             "type": "string",
             "required": True,
         },
+        "notes": {
+            "description": "Notes about the expense",
+            "type": "list",
+
+        },
+        "status": {
+            "description": "The status of the expense",
+            "eallowed": ["reimbursed", "submitted", "unsubmitted",],
+            "type": "string"
+        },
+        "reimbursements": {
+            "description": "Reimbursements for the expense",
+            "schema": {
+                "schema": {
+                    'amount': {"description": 'amount for reimbursements',
+                               "type": "integer",
+                               },
+                    'date': {"description": "date of reimbursement",
+                             "anyof_type": ["string", "date"],
+                             },
+                    'submission_date': {"description": "date of submission",
+                                        "anyof_type": ["string", "date"],
+                                        },
+                    'where': {"description": 'where the reimbursement has been sent',
+                              "type": 'string',
+                              },
+                },
+                "type": "dict"
+            },
+            "type": "list"
+        },
         "expense_type": {
             "description": "The type of expense",
             "allowed": ["travel", "business"],


Test patch:
diff --git a/tests/outputs/a_expense/expenses.yaml b/tests/outputs/a_expense/expenses.yaml
new file mode 100644
index 00000000..73a6081f
--- /dev/null
+++ b/tests/outputs/a_expense/expenses.yaml
@@ -0,0 +1,124 @@
+2006as_timbuktoo:
+  begin_date: 2020-06-20
+  end_date: 2020-06-25
+  expense_type: travel
+  grant_percentages:
+    - 50.0
+    - 50.0
+  grants:
+    - mrsec14
+    - dmref15
+  itemized_expenses:
+    - date: 2020-06-20
+      purpose: registration
+      unsegregated_expense: 0
+      segregated_expense: 0
+    - date: 2020-06-20
+      purpose: home to airport
+      unsegregated_expense: 0
+      segregated_expense: 0
+    - date: 2020-06-20
+      purpose: flights
+      unsegregated_expense: 0
+      segregated_expense: 0
+    - date: 2020-06-20
+      purpose: airport to hotel
+      unsegregated_expense: 0
+      segregated_expense: 0
+    - date: 2020-06-20
+      purpose: hotel
+      unsegregated_expense: 0
+      segregated_expense: 0
+    - date: 2020-06-20
+      purpose: hotel to airport
+      unsegregated_expense: 0
+      segregated_expense: 0
+    - date: 2020-06-20
+      purpose: airport to home
+      unsegregated_expense: 0
+      segregated_expense: 0
+    - date: 2020-06-20
+      purpose: meals
+      unsegregated_expense: 0
+      segregated_expense: 0
+  notes: []
+  overall_purpose: travel to timbuktoo
+  payee: ashaaban
+  reimbursements:
+    - amount: 0
+      date: tbd
+      submission_date: tbd
+      where: bank
+  status: unsubmitted
+test:
+  expense_type: business
+  grant_percentages:
+    - '50'
+    - '50'
+  grants:
+    - dmref15
+    - SymPy-1.1
+  itemized_expenses:
+    - day: 1
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 10
+      segregated_expense: 0
+    - day: 2
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 20
+      segregated_expense: 0
+    - day: 3
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 30
+      segregated_expense: 0
+    - day: 4
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 40
+      segregated_expense: 0
+    - day: 5
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 50
+      segregated_expense: 0
+    - day: 6
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 60
+      segregated_expense: 0
+    - day: 7
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 70
+      segregated_expense: 0
+    - day: 8
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 80
+      segregated_expense: 0
+    - day: 9
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 90
+      segregated_expense: 0
+    - day: 10
+      month: Jan
+      year: 2018
+      purpose: test
+      unsegregated_expense: 100
+      segregated_expense: 0
+  overall_purpose: testing the databallectionsse
+  payee: scopatz
+  project: Cyclus
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 549920ae..df512d20 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -21,6 +21,10 @@ helper_map = [
       "-a","Kurt Godel", "MC Escher", "Johann Sebastian Bach","-c", "Bitcoin",
       "--other_agencies", "Flatland","-n", "this is a sample added proposal"],
      "20_anewproposal has been added in proposals\n"),
+    (["helper", "a_expense", "159.18", "timbuktoo", "travel to timbuktoo",
+      "--grants", "mrsec14", "dmref15", "--payee", "ashaaban",
+      "--where", "bank", "--begin_date", "2020-06-20", "--end_date", "2020-06-25"],
+     "2006as_timbuktoo has been added in expenses\n"),
     (["helper", "l_milestones", "--verbose"],
      "2021-01-01: lead: lyang, 20ly_newprojectum, status: proposed\n    Type: \n    Title: deliverable\n    log url: \n    Purpose: deliver\n    Audience: beginning grad in chemistry\n2020-05-20: lead: lyang, 20ly_newprojectum, status: proposed\n    Type: meeting\n    Title: Project lead presentation\n    log url: \n    Purpose: to act as an example milestone.  The date is the date it was finished.  delete the field until it is finished.  In this case, the lead will present what they think is the project after their reading. Add more milestones as needed.\n    Audience: lyang, scopatz, ascopatz\n2020-05-06: lead: lyang, 20ly_newprojectum, status: proposed\n    Type: meeting\n    Title: Kick off meeting\n    log url: \n    Purpose: introduce project to the lead\n    Audience: lyang, scopatz, ascopatz\n"
      ),